Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877

This commit is contained in:
onysd 2026-08-03 23:29:20 +03:00
commit ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions

View file

@ -26,6 +26,13 @@ type AccountSettingsStore interface {
SaveAccountSettings(ctx context.Context, userID int64, settings domain.AccountSettings) error
}
// AccountSettingsBatchStore is the cold-load boundary for the per-user account
// settings read model. Production stores implement it so a 100-user
// users.getRequirementsToContact request never becomes 100 SQL queries.
type AccountSettingsBatchStore interface {
GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error)
}
// NotifySettingsStore persists per-scope notification settings (specific peer /
// forum topic / the three category defaults: users, chats, broadcasts).
type NotifySettingsStore interface {

View file

@ -0,0 +1,49 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// AccountRatingStore owns the composite rating read model and its contribution
// ledger.
//
// The read model is derived: SaveAccountRating writes a recomputed projection,
// AccountRatingSignals gathers the raw inputs from the contributing tables, and
// the ledger keeps manual adjustments that must survive a recompute.
type AccountRatingStore interface {
// AccountRating returns the stored projection. Missing rows return
// domain.ErrAccountRatingNotFound so callers can distinguish "never computed"
// from "computed as zero".
AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error)
// AccountRatingBatch resolves several users in one round trip. Users without
// a row are absent from the map.
AccountRatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error)
// SaveAccountRating upserts the projection using optimistic concurrency on
// the stored version; a stale version reports changed=false.
SaveAccountRating(ctx context.Context, rating domain.AccountRating) (stored domain.AccountRating, changed bool, err error)
// AccountRatingSignals gathers the raw contribution snapshot for one user,
// including the manual total carried from the ledger.
AccountRatingSignals(ctx context.Context, userID int64) (domain.AccountRatingSignals, error)
// AdjustAccountRating appends a manual adjustment. Replaying the same
// CommandKey returns the recorded event and applied=false.
AdjustAccountRating(ctx context.Context, req domain.AdjustAccountRatingRequest) (event domain.AccountRatingEvent, applied bool, err error)
// ListAccountRatings is the admin leaderboard query with keyset paging.
ListAccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error)
// AccountRatingEvents returns the ledger for one user, newest first.
AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error)
// StaleAccountRatings returns user ids whose projection is older than the
// given horizon, for the background recompute worker.
StaleAccountRatings(ctx context.Context, olderThanUnix int64, limit int) ([]int64, error)
// UnratedAccounts returns user ids that have no projection at all, oldest
// account first, for the same worker.
//
// Without this the read model can never populate itself: StaleAccountRatings
// walks account_rating, so it can only refresh rows that already exist, and the
// very first row for a user would have to come from an operator recomputing
// that user by hand. Seeding is what makes the local admin leaderboard useful.
//
// Deleted accounts and bots are excluded: neither has a rating to show.
UnratedAccounts(ctx context.Context, limit int) ([]int64, error)
}

View file

@ -0,0 +1,13 @@
package store
import (
"context"
"time"
"telesrv/internal/domain"
)
type AuthDeliveryReportStore interface {
CreateAuthDeliveryReport(ctx context.Context, report domain.AuthDeliveryReport) (domain.AuthDeliveryReport, bool, error)
DeleteExpiredAuthDeliveryReports(ctx context.Context, olderThan time.Time, limit int) (int, error)
}

View file

@ -0,0 +1,97 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// BotVerificationStore owns third-party verification: the icon catalogue, verifier
// status, the granted marks and the application queue in front of them.
//
// Reads on the projection path (PeerVerification / PeerVerificationBatch) are on
// every peer serialisation, so they must be cheap. The wire model carries one
// BotVerification, and the store therefore permits exactly one mark per peer.
type BotVerificationStore interface {
// --- icon catalogue ---
// UpsertVerificationIcon adds or updates a catalogue entry by document id.
UpsertVerificationIcon(ctx context.Context, icon domain.VerificationIcon) (domain.VerificationIcon, error)
// SetVerificationIconActive retires or restores an entry. Marks already granted
// with it keep rendering: the icon id is denormalised onto the mark.
SetVerificationIconActive(ctx context.Context, iconID int64, active bool) (domain.VerificationIcon, error)
// VerificationIcon reads one entry by id.
VerificationIcon(ctx context.Context, iconID int64) (domain.VerificationIcon, error)
// VerificationIconByDocument reads one entry by its custom emoji document id.
VerificationIconByDocument(ctx context.Context, documentID int64) (domain.VerificationIcon, error)
// ListVerificationIcons lists the catalogue, newest first.
ListVerificationIcons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error)
// --- verifier status ---
// UpsertBotVerifierSettings grants or updates verifier status. Optimistic
// locking on the stored version keeps two operators from clobbering each other.
UpsertBotVerifierSettings(ctx context.Context, settings domain.BotVerifierSettings) (domain.BotVerifierSettings, error)
// SetBotVerifierEnabled flips the operator kill switch. Existing marks stay,
// but the verifier can grant nothing new and its settings stop being projected.
SetBotVerifierEnabled(ctx context.Context, botID int64, enabled bool) (domain.BotVerifierSettings, error)
// DeleteBotVerifierSettings removes verifier status; its marks cascade away with
// it, because a mark whose verifier no longer exists has nothing to render.
DeleteBotVerifierSettings(ctx context.Context, botID int64) (bool, error)
// BotVerifierSettings reads one verifier's status, enabled or not.
BotVerifierSettings(ctx context.Context, botID int64) (domain.BotVerifierSettings, error)
// BotVerifierSettingsBatch resolves several bots in one round trip for the
// botInfo projection; bots without verifier status are absent from the map.
BotVerifierSettingsBatch(ctx context.Context, botIDs []int64) (map[int64]domain.BotVerifierSettings, error)
// ListBotVerifiers lists verifier bots for the admin panel.
ListBotVerifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error)
// --- granted marks ---
// GrantCustomVerification creates or updates the peer's mark. A different
// verifier replaces the current mark rather than leaving hidden fallback state.
// The icon is taken from the verifier's settings at grant time and the caller
// has already resolved the description through
// domain.BotVerifierSettings.DescriptionFor.
GrantCustomVerification(ctx context.Context, mark domain.CustomVerification) (domain.CustomVerification, bool, error)
// RevokeCustomVerification removes this verifier's mark from the peer and
// reports whether anything was removed, so a repeated revoke is a no-op.
RevokeCustomVerification(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error)
// CustomVerification reads one verifier's mark on a peer.
CustomVerification(ctx context.Context, verifierBotID int64, peer domain.Peer) (domain.CustomVerification, error)
// PeerVerification returns the peer's single mark. Missing marks report
// domain.ErrCustomVerificationNotFound.
PeerVerification(ctx context.Context, peer domain.Peer) (domain.CustomVerification, error)
// PeerVerificationBatch resolves the projection for many peers at once. This is
// the call on the hot serialisation path, so peers without a mark are simply
// absent instead of erroring.
PeerVerificationBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer]domain.CustomVerification, error)
// CountCustomVerifications reports how many peers a verifier has marked, for the
// per-verifier bound.
CountCustomVerifications(ctx context.Context, verifierBotID int64) (int, error)
// ListCustomVerifications is the admin listing query with keyset paging.
ListCustomVerifications(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error)
// --- application queue ---
// CreateCustomVerificationRequest files an application. A pending application on
// the same (verifier, peer) reports domain.ErrCustomVerificationRequestExists.
CreateCustomVerificationRequest(ctx context.Context, req domain.CustomVerificationRequest) (domain.CustomVerificationRequest, error)
// DecideCustomVerificationRequest moves an application through its status
// machine. approve=true grants the mark in the same transaction through the
// supplied callback, so an approved application can never exist without its
// mark; revoke removes it the same way.
DecideCustomVerificationRequest(ctx context.Context, requestID int64, version int64, status domain.CustomVerificationRequestStatus, decidedBy, reason, note string, apply func(ctx context.Context, req domain.CustomVerificationRequest) error) (domain.CustomVerificationRequest, bool, error)
// CustomVerificationRequest reads one application.
CustomVerificationRequest(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error)
// PendingCustomVerificationRequest returns the live application for a
// (verifier, peer) pair, if any.
PendingCustomVerificationRequest(ctx context.Context, verifierBotID int64, peer domain.Peer) (domain.CustomVerificationRequest, error)
// ListCustomVerificationRequests is the review-queue query with keyset paging.
ListCustomVerificationRequests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error)
// CustomVerificationRequestsForApplicant returns an applicant's own history for
// the verifier bot's /status command.
CustomVerificationRequestsForApplicant(ctx context.Context, applicantUserID int64, limit int) ([]domain.CustomVerificationRequest, error)
// CustomVerificationRequestCounts is the queue summary by status.
CustomVerificationRequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error)
}

View file

@ -82,8 +82,6 @@ type ChannelStore interface {
ListTopMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
ListRecentMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
ClearRecentMessageReactions(ctx context.Context, userID int64) error
ListSavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error)
UpsertSavedReactionTag(ctx context.Context, tag domain.SavedReactionTag) error
GetPremiumBoostStatus(ctx context.Context, viewerUserID, channelID int64, now int) (domain.PremiumBoostStatus, error)
ListPremiumBoosts(ctx context.Context, viewerUserID, channelID int64, gifts bool, offset string, limit, now int) (domain.PremiumBoostList, error)
GetPremiumMyBoosts(ctx context.Context, userID int64, now, premiumUntil int) (domain.PremiumMyBoosts, error)
@ -188,6 +186,10 @@ type ChannelStore interface {
ListActiveChannelMembers(ctx context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error)
ListChannelInviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error)
FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
// FilterChannelMessageAudienceIDs authoritatively intersects a bounded online
// candidate set with users allowed to receive channel message-box updates:
// active members plus non-banned public-channel preview subscribers.
FilterChannelMessageAudienceIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
MaxChannelPts(ctx context.Context, channelID int64) (int, error)
// MaxChannelPtsBatch returns existing channel watermarks with one bounded store round trip.
// Missing/deleted ids are omitted so a stale process-local membership key cannot poison the

View file

@ -0,0 +1,13 @@
package store
import (
"context"
"time"
"telesrv/internal/domain"
)
type ClientTelemetryStore interface {
CreateClientTelemetry(ctx context.Context, event domain.ClientTelemetryEvent) (domain.ClientTelemetryEvent, bool, error)
DeleteExpiredClientTelemetry(ctx context.Context, olderThan time.Time, limit int) (int, error)
}

View file

@ -76,6 +76,11 @@ type PhoneCode struct {
VerifiedEmail bool
RequireSignUp bool
LoginEmailHash string
// RecoveryBinding ties a password-recovery code to the exact 2FA state
// which existed when the code was issued. A password or recovery-email
// change makes an older code unusable without relying on cross-store
// best-effort invalidation.
RecoveryBinding string
// AccountDeletionHash is the hex-encoded SHA-256 digest of the validated
// confirmphone link token. It binds account.confirmPhone to one pending
// deletion without persisting the raw link credential in the code record.

View file

@ -0,0 +1,61 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// UsernameRegistryStore reads a peer's full username list. The list is the
// projection source for the TL usernames vector, so every caller sees the same
// stored order the client will render. Reorder may promote a collectible ahead
// of the editable slot; the first active row is also the Layer 228 main scalar.
type UsernameRegistryStore interface {
// PeerUsernames returns the peer's registry rows in projection order.
PeerUsernames(ctx context.Context, peer domain.Peer) ([]domain.Username, error)
// PeerUsernamesBatch resolves several peers in one round trip; peers absent
// from the result simply hold no usernames.
PeerUsernamesBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error)
// SetUsernameActive toggles one collectible row. It never touches the
// editable slot and returns domain.ErrUsernameNotCollectible if asked to.
SetUsernameActive(ctx context.Context, peer domain.Peer, username string, active bool) (bool, error)
// ReorderUsernames rewrites collectible sort order. order must be a
// permutation of the peer's collectible usernames.
ReorderUsernames(ctx context.Context, peer domain.Peer, order []string) (bool, error)
// DeactivateAllUsernames clears the active flag on every collectible row and
// reports whether anything changed. The editable slot is left alone.
DeactivateAllUsernames(ctx context.Context, peer domain.Peer) (bool, error)
}
// CollectibleUsernameStore owns the collectible asset lifecycle: minting into the
// operator vault, assigning to a holder, returning to the vault and burning.
//
// Every mutation is expected to be atomic with the corresponding username
// registry row, so an asset can never be owned without being resolvable and can
// never be resolvable without being owned.
type CollectibleUsernameStore interface {
// MintCollectibleUsername creates the asset. A request carrying an owner
// assigns it in the same transaction. Replaying the same CommandKey returns
// the original asset and reports created=false.
MintCollectibleUsername(ctx context.Context, req domain.MintCollectibleUsernameRequest) (asset domain.CollectibleUsername, created bool, err error)
// TransferCollectibleUsername moves the asset to req.To, out of the vault or
// from the current holder. Replay by CommandKey is a no-op returning the
// current state with changed=false.
TransferCollectibleUsername(ctx context.Context, req domain.TransferCollectibleUsernameRequest) (asset domain.CollectibleUsername, changed bool, err error)
// RevokeCollectibleUsername returns the asset to the vault, or burns it when
// req.Burn is set. Burning releases the name back to the free pool.
RevokeCollectibleUsername(ctx context.Context, req domain.RevokeCollectibleUsernameRequest) (asset domain.CollectibleUsername, changed bool, err error)
// DeleteCollectibleUsername removes the live asset for a name completely --
// registry row, asset and provenance -- and frees the name for any use. A
// replay finds nothing live left and reports deleted=false without an error,
// because the record a command key would key on is gone.
DeleteCollectibleUsername(ctx context.Context, req domain.DeleteCollectibleUsernameRequest) (deleted bool, err error)
// CollectibleUsername looks the asset up by name.
CollectibleUsername(ctx context.Context, username string) (domain.CollectibleUsername, error)
// CollectibleUsernameByID looks the asset up by identity.
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
// ListCollectibleUsernames is the admin listing query with keyset paging.
ListCollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error)
// CollectibleUsernameTransfers returns the provenance log, newest first.
CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error)
}

View file

@ -0,0 +1,392 @@
package memory
import (
"context"
"sort"
"sync"
"time"
"telesrv/internal/domain"
)
// Default page sizes for the rating reads, so an unset limit resolves to a
// finite page the way the PostgreSQL LIMIT does.
const (
defaultAccountRatingListLimit = 50
defaultAccountRatingEventLimit = 50
defaultAccountRatingStaleLimit = 50
)
// AccountRatingStore is the in-memory implementation of store.AccountRatingStore.
// It reproduces the invariants migration 0151 encodes:
//
// - account_rating is keyed by user_id, so one projection row per user.
// - the version CHECK plus optimistic concurrency: a write is applied only when
// it carries the successor of the stored version, which is exactly what
// domain.ResolveAccountRatingPending produces.
// - the pending pair CHECK: a pending delta and its date exist together or not
// at all.
// - the component CHECKs: stars/activity/penalty components and level are
// non-negative, and next_level_stars is either absent or above
// current_level_stars.
// - account_rating_events_command_idx: a replayed command key never appends a
// second adjustment.
type AccountRatingStore struct {
mu sync.Mutex
nextID int64
// ratings is the account_rating read model.
ratings map[int64]domain.AccountRating
// events is the append-only contribution ledger in insertion order.
events []domain.AccountRatingEvent
// commands maps an adjustment command key onto the ledger row it created.
commands map[string]int64
// signals holds the raw contribution snapshot per user.
//
// PostgreSQL aggregates it from stars_transactions, message counts, saved
// gifts and moderation cases. In memory those live in unrelated store types
// (StarsStore, MessageStore, StarGiftStore, ModerationReportStore) that this
// store has no handle on, and wiring them in would make the rating depend on
// which stores a test happens to construct. The snapshot is therefore
// injected -- deterministic, and identical for a unit test and for the
// recompute worker, which is what domain.AccountRatingSignals promises. Only
// the manual total is derived here, from the ledger, because the ledger is
// this store's own data.
signals map[int64]domain.AccountRatingSignals
// accounts is the account universe UnratedAccounts seeds from, in declaration
// order.
//
// PostgreSQL reads it from the users table. This store has no users table and
// inventing one from whichever ids happen to appear in the ledger would be
// circular -- an account with no rating and no adjustment is exactly the case
// seeding exists for. So the universe is declared, like signals above.
accounts []int64
}
// NewAccountRatingStore creates an empty rating store.
func NewAccountRatingStore() *AccountRatingStore {
return &AccountRatingStore{
nextID: 1,
ratings: make(map[int64]domain.AccountRating),
commands: make(map[string]int64),
signals: make(map[int64]domain.AccountRatingSignals),
}
}
// SeedAccountRatingSignals installs raw contribution snapshots for tests in other
// packages; see the signals field for why they are injected rather than derived.
func (s *AccountRatingStore) SeedAccountRatingSignals(signals ...domain.AccountRatingSignals) {
for _, item := range signals {
s.setAccountRatingSignals(item)
}
}
// setAccountRatingSignals is the same hook for this package's tests.
func (s *AccountRatingStore) setAccountRatingSignals(signals domain.AccountRatingSignals) {
if signals.UserID <= 0 {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.signals[signals.UserID] = signals
}
// AccountRating returns the stored projection, or domain.ErrAccountRatingNotFound
// when the user was never computed.
func (s *AccountRatingStore) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
s.mu.Lock()
defer s.mu.Unlock()
rating, ok := s.ratings[userID]
if !ok {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
return rating, nil
}
// AccountRatingBatch resolves several users at once; users without a row are
// absent from the map.
func (s *AccountRatingStore) AccountRatingBatch(_ context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
out := make(map[int64]domain.AccountRating, len(userIDs))
if len(userIDs) == 0 {
return out, nil
}
s.mu.Lock()
defer s.mu.Unlock()
for _, userID := range userIDs {
if userID <= 0 {
continue
}
if rating, ok := s.ratings[userID]; ok {
out[userID] = rating
}
}
return out, nil
}
// SaveAccountRating upserts the projection under optimistic concurrency: the
// incoming Version must be the successor of the stored one, which is what
// domain.ResolveAccountRatingPending computes. A stale or missing version leaves
// the stored row untouched and reports changed=false, so a caller that lost a
// race can re-read and retry.
func (s *AccountRatingStore) SaveAccountRating(_ context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
if rating.UserID <= 0 {
// account_rating.user_id references users(id): there is no row to write.
return domain.AccountRating{}, false, domain.ErrAccountRatingNotFound
}
s.mu.Lock()
defer s.mu.Unlock()
stored, exists := s.ratings[rating.UserID]
if rating.Version != stored.Version+1 {
if !exists {
return domain.AccountRating{}, false, nil
}
return stored, false, nil
}
next := normalizeAccountRating(rating)
s.ratings[next.UserID] = next
return next, true, nil
}
// AccountRatingSignals returns the injected raw snapshot with the manual total
// taken from the ledger, mirroring the PostgreSQL aggregate. A user with no
// contributions reports zeros rather than an error, because the aggregate has no
// "missing row" state.
func (s *AccountRatingStore) AccountRatingSignals(_ context.Context, userID int64) (domain.AccountRatingSignals, error) {
if userID <= 0 {
return domain.AccountRatingSignals{}, domain.ErrAccountRatingNotFound
}
s.mu.Lock()
defer s.mu.Unlock()
signals := s.signals[userID]
signals.UserID = userID
signals.Manual += s.manualTotalLocked(userID)
return signals, nil
}
// AdjustAccountRating appends a manual adjustment. A replayed command key returns
// the recorded event with applied=false and appends nothing.
func (s *AccountRatingStore) AdjustAccountRating(_ context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
if err := req.Validate(); err != nil {
return domain.AccountRatingEvent{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if req.CommandKey != "" {
if id, ok := s.commands[req.CommandKey]; ok {
for _, event := range s.events {
if event.ID == id {
return event, false, nil
}
}
}
}
event := domain.AccountRatingEvent{
ID: s.nextID,
UserID: req.UserID,
Kind: domain.AccountRatingEventManual,
Amount: req.Amount,
Reason: req.Reason,
Actor: req.Actor,
CommandKey: req.CommandKey,
CreatedAt: time.Now().UTC(),
}
s.nextID++
s.events = append(s.events, event)
if event.CommandKey != "" {
s.commands[event.CommandKey] = event.ID
}
return event, true, nil
}
// ListAccountRatings is the admin leaderboard: level desc, stars desc, user id
// asc, matching account_rating_leaderboard_idx. BeforeID is the keyset cursor and
// names the last row of the previous page.
func (s *AccountRatingStore) ListAccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
limit := filter.Limit
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
s.mu.Lock()
defer s.mu.Unlock()
cursor, hasCursor := s.ratings[filter.BeforeID]
out := make([]domain.AccountRating, 0, len(s.ratings))
for _, rating := range s.ratings {
if filter.MinLevel > 0 && rating.Level < filter.MinLevel {
continue
}
if filter.UserID > 0 && rating.UserID != filter.UserID {
continue
}
switch {
case filter.BeforeID <= 0:
case hasCursor:
// Keyset paging over the leaderboard order.
if !accountRatingLess(cursor, rating) {
continue
}
default:
// The cursor row is gone; fall back to the id tiebreak alone so paging
// still terminates.
if rating.UserID <= filter.BeforeID {
continue
}
}
out = append(out, rating)
}
sort.Slice(out, func(i, j int) bool { return accountRatingLess(out[i], out[j]) })
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
// AccountRatingEvents returns the ledger for one user, newest first.
func (s *AccountRatingStore) AccountRatingEvents(_ context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
if limit <= 0 {
limit = defaultAccountRatingEventLimit
}
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.AccountRatingEvent, 0, limit)
for i := len(s.events) - 1; i >= 0 && len(out) < limit; i-- {
if s.events[i].UserID != userID {
continue
}
out = append(out, s.events[i])
}
return out, nil
}
// StaleAccountRatings returns the users whose projection predates the horizon,
// oldest first, which is the order account_rating_stale_idx serves.
func (s *AccountRatingStore) StaleAccountRatings(_ context.Context, olderThanUnix int64, limit int) ([]int64, error) {
if limit <= 0 {
limit = defaultAccountRatingStaleLimit
}
horizon := time.Unix(olderThanUnix, 0).UTC()
s.mu.Lock()
defer s.mu.Unlock()
stale := make([]domain.AccountRating, 0, len(s.ratings))
for _, rating := range s.ratings {
if rating.ComputedAt.Before(horizon) {
stale = append(stale, rating)
}
}
sort.Slice(stale, func(i, j int) bool {
if !stale[i].ComputedAt.Equal(stale[j].ComputedAt) {
return stale[i].ComputedAt.Before(stale[j].ComputedAt)
}
return stale[i].UserID < stale[j].UserID
})
if len(stale) > limit {
stale = stale[:limit]
}
out := make([]int64, 0, len(stale))
for _, rating := range stale {
out = append(out, rating.UserID)
}
return out, nil
}
// SeedAccounts declares the account universe UnratedAccounts walks. Repeating an
// id is a no-op, so a test can declare accounts as it creates them.
func (s *AccountRatingStore) SeedAccounts(userIDs ...int64) {
s.mu.Lock()
defer s.mu.Unlock()
known := make(map[int64]struct{}, len(s.accounts))
for _, id := range s.accounts {
known[id] = struct{}{}
}
for _, id := range userIDs {
if id <= 0 {
continue
}
if _, ok := known[id]; ok {
continue
}
known[id] = struct{}{}
s.accounts = append(s.accounts, id)
}
}
// UnratedAccounts returns declared accounts that have no projection yet, in
// declaration order -- the memory stand-in for PostgreSQL's oldest-account-first
// walk. A store nobody seeded reports no candidates rather than erroring: the
// worker treats that as "nothing to seed", which is the truth.
func (s *AccountRatingStore) UnratedAccounts(_ context.Context, limit int) ([]int64, error) {
if limit <= 0 {
limit = defaultAccountRatingStaleLimit
}
s.mu.Lock()
defer s.mu.Unlock()
out := make([]int64, 0, limit)
for _, id := range s.accounts {
if _, rated := s.ratings[id]; rated {
continue
}
out = append(out, id)
if len(out) == limit {
break
}
}
return out, nil
}
// manualTotalLocked sums the manual ledger rows, the only kind that survives a
// recompute.
func (s *AccountRatingStore) manualTotalLocked(userID int64) int64 {
var total int64
for _, event := range s.events {
if event.UserID == userID && event.Kind == domain.AccountRatingEventManual {
total += event.Amount
}
}
return total
}
// normalizeAccountRating makes the rows the table's CHECK constraints forbid
// unrepresentable. PostgreSQL raises an opaque constraint error there rather than
// a domain error, so the memory store folds the impossible shapes onto the
// closest representable one instead of inventing an error the RPC layer would
// then have to handle only in tests.
func normalizeAccountRating(rating domain.AccountRating) domain.AccountRating {
if rating.Level < 0 {
rating.Level = 0
}
if rating.Level > domain.MaxAccountRatingLevel {
rating.Level = domain.MaxAccountRatingLevel
}
if rating.CurrentLevelStars < 0 {
rating.CurrentLevelStars = 0
}
if rating.StarsComponent < 0 {
rating.StarsComponent = 0
}
if rating.ActivityComponent < 0 {
rating.ActivityComponent = 0
}
if rating.PenaltyComponent < 0 {
rating.PenaltyComponent = 0
}
if !rating.HasNextLevel || rating.NextLevelStars <= rating.CurrentLevelStars {
rating.HasNextLevel = false
rating.NextLevelStars = 0
}
// The pending delta and its date only exist together.
if rating.PendingStars == 0 || rating.PendingDate.IsZero() {
rating.PendingStars = 0
rating.PendingDate = time.Time{}
}
return rating
}
// accountRatingLess is the leaderboard order: highest level first, then the
// larger score, then the lower user id as a stable tiebreak.
func accountRatingLess(a, b domain.AccountRating) bool {
if a.Level != b.Level {
return a.Level > b.Level
}
if a.Stars != b.Stars {
return a.Stars > b.Stars
}
return a.UserID < b.UserID
}

View file

@ -0,0 +1,425 @@
package memory
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
var _ store.AccountRatingStore = (*AccountRatingStore)(nil)
// accountRatingFixture builds a projection the table's CHECK constraints accept.
func accountRatingFixture(userID, stars, version int64, computedAt time.Time) domain.AccountRating {
level, current, next, hasNext := domain.AccountRatingLevelForStars(stars)
return domain.AccountRating{
UserID: userID,
Level: level,
Stars: stars,
CurrentLevelStars: current,
NextLevelStars: next,
HasNextLevel: hasNext,
StarsComponent: stars,
ComputedAt: computedAt,
UpdatedAt: computedAt,
Version: version,
}
}
func TestSaveAccountRating(t *testing.T) {
ctx := context.Background()
now := time.Unix(1700000000, 0).UTC()
tests := []struct {
name string
seed []domain.AccountRating
input domain.AccountRating
wantErr error
wantChanged bool
check func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating)
}{
{
name: "insert",
input: accountRatingFixture(11, 450, 1, now),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.Level != 2 || stored.Stars != 450 || stored.Version != 1 ||
stored.CurrentLevelStars != 400 || stored.NextLevelStars != 900 || !stored.HasNextLevel {
t.Fatalf("stored=%+v", stored)
}
read, err := s.AccountRating(ctx, 11)
if err != nil || read != stored {
t.Fatalf("read=%+v err=%v", read, err)
}
},
},
{
name: "successor version applied",
seed: []domain.AccountRating{accountRatingFixture(11, 450, 1, now)},
input: accountRatingFixture(11, 1000, 2, now.Add(time.Minute)),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.Version != 2 || stored.Stars != 1000 || stored.Level != 3 {
t.Fatalf("stored=%+v", stored)
}
},
},
{
name: "replayed version is stale",
seed: []domain.AccountRating{accountRatingFixture(11, 450, 1, now)},
input: accountRatingFixture(11, 9999, 1, now.Add(time.Minute)),
wantChanged: false,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
// The loser of the race gets the current row back, untouched.
if stored.Stars != 450 || stored.Version != 1 {
t.Fatalf("stored=%+v", stored)
}
read, err := s.AccountRating(ctx, 11)
if err != nil || read.Stars != 450 || read.Version != 1 {
t.Fatalf("read=%+v err=%v", read, err)
}
},
},
{
name: "version from the future is rejected",
seed: []domain.AccountRating{accountRatingFixture(11, 450, 1, now)},
input: accountRatingFixture(11, 9999, 7, now.Add(time.Minute)),
wantChanged: false,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.Stars != 450 || stored.Version != 1 {
t.Fatalf("stored=%+v", stored)
}
},
},
{
name: "insert must carry version one",
input: accountRatingFixture(11, 450, 3, now),
wantChanged: false,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored != (domain.AccountRating{}) {
t.Fatalf("stored=%+v", stored)
}
if _, err := s.AccountRating(ctx, 11); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("row was written: %v", err)
}
},
},
{
name: "no user",
input: accountRatingFixture(0, 450, 1, now),
wantErr: domain.ErrAccountRatingNotFound,
},
{
name: "pending delta without a date is dropped",
input: func() domain.AccountRating {
rating := accountRatingFixture(11, 450, 1, now)
rating.PendingStars = 120
return rating
}(),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.PendingStars != 0 || !stored.PendingDate.IsZero() {
t.Fatalf("stored=%+v", stored)
}
if _, ok := stored.PendingLevel(); ok {
t.Fatalf("pending projection survived: %+v", stored)
}
},
},
{
name: "pending pair is kept",
input: func() domain.AccountRating {
rating := accountRatingFixture(11, 450, 1, now)
rating.PendingStars = 500
rating.PendingDate = now.Add(time.Hour)
return rating
}(),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
pending, ok := stored.PendingLevel()
if !ok || pending.Stars != 950 || pending.Level != 3 {
t.Fatalf("pending=%+v ok=%v", pending, ok)
}
},
},
{
name: "impossible components are folded",
input: func() domain.AccountRating {
rating := accountRatingFixture(11, 450, 1, now)
rating.Level = -3
rating.StarsComponent = -10
rating.ActivityComponent = -1
rating.PenaltyComponent = -7
rating.CurrentLevelStars = -5
rating.NextLevelStars = -9
rating.HasNextLevel = true
return rating
}(),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.Level != 0 || stored.StarsComponent != 0 || stored.ActivityComponent != 0 ||
stored.PenaltyComponent != 0 || stored.CurrentLevelStars != 0 {
t.Fatalf("stored=%+v", stored)
}
if stored.HasNextLevel || stored.NextLevelStars != 0 {
t.Fatalf("next level survived: %+v", stored)
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := NewAccountRatingStore()
for _, seed := range tc.seed {
if _, changed, err := s.SaveAccountRating(ctx, seed); err != nil || !changed {
t.Fatalf("seed changed=%v err=%v", changed, err)
}
}
stored, changed, err := s.SaveAccountRating(ctx, tc.input)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err=%v want %v", err, tc.wantErr)
}
if changed != tc.wantChanged {
t.Fatalf("changed=%v want %v", changed, tc.wantChanged)
}
if tc.wantErr != nil {
return
}
if tc.check != nil {
tc.check(t, s, stored)
}
})
}
}
func TestAccountRatingReadsAndLeaderboard(t *testing.T) {
ctx := context.Background()
now := time.Unix(1700000000, 0).UTC()
s := NewAccountRatingStore()
for _, rating := range []domain.AccountRating{
accountRatingFixture(11, 2500, 1, now),
accountRatingFixture(12, 450, 1, now),
accountRatingFixture(13, 2500, 1, now),
accountRatingFixture(14, 0, 1, now),
} {
if _, changed, err := s.SaveAccountRating(ctx, rating); err != nil || !changed {
t.Fatalf("seed %d changed=%v err=%v", rating.UserID, changed, err)
}
}
batch, err := s.AccountRatingBatch(ctx, []int64{11, 13, 99, 0, 11})
if err != nil || len(batch) != 2 {
t.Fatalf("batch=%+v err=%v", batch, err)
}
if batch[11].Stars != 2500 || batch[13].Stars != 2500 {
t.Fatalf("batch=%+v", batch)
}
if empty, err := s.AccountRatingBatch(ctx, nil); err != nil || len(empty) != 0 {
t.Fatalf("empty batch=%+v err=%v", empty, err)
}
// level desc, stars desc, user id asc.
board, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{})
if err != nil || len(board) != 4 {
t.Fatalf("board=%+v err=%v", board, err)
}
want := []int64{11, 13, 12, 14}
for i, userID := range want {
if board[i].UserID != userID {
t.Fatalf("board order=%+v want %v", board, want)
}
}
page, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{Limit: 2})
if err != nil || len(page) != 2 || page[1].UserID != 13 {
t.Fatalf("page=%+v err=%v", page, err)
}
next, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{
BeforeID: page[len(page)-1].UserID, Limit: 2,
})
if err != nil || len(next) != 2 || next[0].UserID != 12 || next[1].UserID != 14 {
t.Fatalf("next=%+v err=%v", next, err)
}
filtered, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: 5})
if err != nil || len(filtered) != 2 {
t.Fatalf("filtered=%+v err=%v", filtered, err)
}
single, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{UserID: 12})
if err != nil || len(single) != 1 || single[0].UserID != 12 {
t.Fatalf("single=%+v err=%v", single, err)
}
if _, err := s.AccountRating(ctx, 99); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("unknown user err=%v", err)
}
}
func TestAdjustAccountRating(t *testing.T) {
ctx := context.Background()
s := NewAccountRatingStore()
req := domain.AdjustAccountRatingRequest{
UserID: 11, Amount: 750, Reason: "contest prize", Actor: "admin", CommandKey: "cmd-adjust",
}
event, applied, err := s.AdjustAccountRating(ctx, req)
if err != nil || !applied {
t.Fatalf("adjust applied=%v err=%v", applied, err)
}
if event.ID == 0 || event.Kind != domain.AccountRatingEventManual || event.Amount != 750 ||
event.Reason != "contest prize" || event.Actor != "admin" || event.CreatedAt.IsZero() {
t.Fatalf("event=%+v", event)
}
// Replaying the command key returns the recorded row and appends nothing.
replay, applied, err := s.AdjustAccountRating(ctx, req)
if err != nil || applied {
t.Fatalf("replay applied=%v err=%v", applied, err)
}
if replay != event {
t.Fatalf("replay=%+v want %+v", replay, event)
}
ledger, err := s.AccountRatingEvents(ctx, 11, 10)
if err != nil || len(ledger) != 1 {
t.Fatalf("ledger=%+v err=%v", ledger, err)
}
second := req
second.Amount = -200
second.CommandKey = "cmd-adjust-2"
if _, applied, err := s.AdjustAccountRating(ctx, second); err != nil || !applied {
t.Fatalf("second adjust applied=%v err=%v", applied, err)
}
// Newest first, and other users are not mixed in.
if _, applied, err := s.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: 12, Amount: 5, CommandKey: "cmd-other",
}); err != nil || !applied {
t.Fatalf("other user applied=%v err=%v", applied, err)
}
ledger, err = s.AccountRatingEvents(ctx, 11, 10)
if err != nil || len(ledger) != 2 || ledger[0].Amount != -200 || ledger[1].Amount != 750 {
t.Fatalf("ledger=%+v err=%v", ledger, err)
}
if capped, err := s.AccountRatingEvents(ctx, 11, 1); err != nil || len(capped) != 1 ||
capped[0].Amount != -200 {
t.Fatalf("capped=%+v err=%v", capped, err)
}
// An unkeyed adjustment is always appended.
if _, applied, err := s.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: 11, Amount: 10,
}); err != nil || !applied {
t.Fatalf("unkeyed applied=%v err=%v", applied, err)
}
for _, invalid := range []domain.AdjustAccountRatingRequest{
{UserID: 11, Amount: 0, CommandKey: "cmd-zero"},
{UserID: 0, Amount: 5, CommandKey: "cmd-nouser"},
} {
if _, applied, err := s.AdjustAccountRating(ctx, invalid); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) || applied {
t.Fatalf("invalid adjust applied=%v err=%v", applied, err)
}
}
// The manual total is carried out of the ledger into the signal snapshot.
signals, err := s.AccountRatingSignals(ctx, 11)
if err != nil || signals.UserID != 11 || signals.Manual != 560 {
t.Fatalf("signals=%+v err=%v", signals, err)
}
}
func TestAccountRatingSignals(t *testing.T) {
ctx := context.Background()
s := NewAccountRatingStore()
// A user with no contributions reports zeros rather than an error.
signals, err := s.AccountRatingSignals(ctx, 11)
if err != nil || signals != (domain.AccountRatingSignals{UserID: 11}) {
t.Fatalf("signals=%+v err=%v", signals, err)
}
if _, err := s.AccountRatingSignals(ctx, 0); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("missing user err=%v", err)
}
s.setAccountRatingSignals(domain.AccountRatingSignals{
UserID: 11, StarsReceived: 4000, StarsSpent: 2000, MessagesSent: 300,
AccountAgeDays: 100, GiftsReceived: 4, ModerationCases: 1,
})
if _, applied, err := s.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: 11, Amount: 250, CommandKey: "cmd-bonus",
}); err != nil || !applied {
t.Fatalf("adjust applied=%v err=%v", applied, err)
}
signals, err = s.AccountRatingSignals(ctx, 11)
if err != nil || signals.StarsReceived != 4000 || signals.MessagesSent != 300 ||
signals.ModerationCases != 1 || signals.Manual != 250 {
t.Fatalf("signals=%+v err=%v", signals, err)
}
// The snapshot feeds the domain formula, and the result round-trips.
now := time.Unix(1700000000, 0).UTC()
computed := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
stored, changed, err := s.SaveAccountRating(ctx, computed)
if err != nil || !changed {
t.Fatalf("save changed=%v err=%v", changed, err)
}
if stored.Stars != computed.Stars || stored.Level != computed.Level ||
stored.ManualComponent != 250 {
t.Fatalf("stored=%+v computed=%+v", stored, computed)
}
// A recompute uses the pending resolution the domain owns.
s.setAccountRatingSignals(domain.AccountRatingSignals{UserID: 11, StarsReceived: 40000})
signals, err = s.AccountRatingSignals(ctx, 11)
if err != nil {
t.Fatal(err)
}
recomputed := domain.ResolveAccountRatingPending(stored,
domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now.Add(time.Hour)),
24*time.Hour, now.Add(time.Hour))
saved, changed, err := s.SaveAccountRating(ctx, recomputed)
if err != nil || !changed || saved.Version != stored.Version+1 {
t.Fatalf("saved=%+v changed=%v err=%v", saved, changed, err)
}
if saved.PendingStars <= 0 || saved.PendingDate.IsZero() {
t.Fatalf("pending was not parked: %+v", saved)
}
}
func TestStaleAccountRatings(t *testing.T) {
ctx := context.Background()
now := time.Unix(1700000000, 0).UTC()
s := NewAccountRatingStore()
for _, rating := range []domain.AccountRating{
accountRatingFixture(11, 100, 1, now.Add(-3*time.Hour)),
accountRatingFixture(12, 100, 1, now.Add(-2*time.Hour)),
accountRatingFixture(13, 100, 1, now.Add(-time.Hour)),
accountRatingFixture(14, 100, 1, now),
} {
if _, changed, err := s.SaveAccountRating(ctx, rating); err != nil || !changed {
t.Fatalf("seed %d changed=%v err=%v", rating.UserID, changed, err)
}
}
stale, err := s.StaleAccountRatings(ctx, now.Add(-90*time.Minute).Unix(), 10)
if err != nil || len(stale) != 2 || stale[0] != 11 || stale[1] != 12 {
t.Fatalf("stale=%v err=%v", stale, err)
}
if limited, err := s.StaleAccountRatings(ctx, now.Add(-90*time.Minute).Unix(), 1); err != nil ||
len(limited) != 1 || limited[0] != 11 {
t.Fatalf("limited=%v err=%v", limited, err)
}
if none, err := s.StaleAccountRatings(ctx, now.Add(-4*time.Hour).Unix(), 10); err != nil || len(none) != 0 {
t.Fatalf("none=%v err=%v", none, err)
}
// A recompute refreshes computed_at and takes the row out of the horizon.
refreshed := accountRatingFixture(11, 100, 2, now)
if _, changed, err := s.SaveAccountRating(ctx, refreshed); err != nil || !changed {
t.Fatalf("refresh changed=%v err=%v", changed, err)
}
stale, err = s.StaleAccountRatings(ctx, now.Add(-90*time.Minute).Unix(), 10)
if err != nil || len(stale) != 1 || stale[0] != 12 {
t.Fatalf("stale=%v err=%v", stale, err)
}
}

View file

@ -461,8 +461,9 @@ func (s *AuthorizationStore) DeleteByHash(_ context.Context, userID, hash int64)
return domain.Authorization{}, false, nil
}
// RevokeByHash mirrors PostgreSQL's protocol-key revocation boundary when this
// authorization projection is linked to an in-memory auth-key authority.
// RevokeByHash removes only the business authorization. The protocol auth key
// and any temp binding stay usable for MTProto decryption so a kicked client can
// reconnect and receive AUTH_KEY_UNREGISTERED from the RPC gate.
func (s *AuthorizationStore) RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
s.linkMu.RLock()
defer s.linkMu.RUnlock()
@ -471,27 +472,17 @@ func (s *AuthorizationStore) RevokeByHash(ctx context.Context, userID, hash int6
}
s.authKeys.mu.Lock()
s.mu.Lock()
var (
targetID [8]byte
target domain.Authorization
found bool
)
for id, a := range s.m {
if a.UserID == userID && a.Hash == hash {
targetID, target, found = id, a, true
break
delete(s.m, id)
s.mu.Unlock()
s.authKeys.mu.Unlock()
return a, true, nil
}
}
if !found {
s.mu.Unlock()
s.authKeys.mu.Unlock()
return domain.Authorization{}, false, nil
}
deletedIDs := s.authKeys.deleteProtocolAuthKeyLocked(targetID)
s.authKeys.deleteAuthorizationMirrorsWithHeldLocked(deletedIDs, s)
s.mu.Unlock()
s.authKeys.mu.Unlock()
return target, true, nil
return domain.Authorization{}, false, nil
}
func (s *AuthorizationStore) DeleteByUserExcept(_ context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
@ -517,18 +508,12 @@ func (s *AuthorizationStore) RevokeByUserExcept(ctx context.Context, userID int6
s.authKeys.mu.Lock()
s.mu.Lock()
out := make([]domain.Authorization, 0)
targets := make([][8]byte, 0)
for id, a := range s.m {
if a.UserID == userID && id != keepAuthKeyID {
out = append(out, a)
targets = append(targets, id)
delete(s.m, id)
}
}
deletedIDs := make([][8]byte, 0, len(targets))
for _, id := range targets {
deletedIDs = append(deletedIDs, s.authKeys.deleteProtocolAuthKeyLocked(id)...)
}
s.authKeys.deleteAuthorizationMirrorsWithHeldLocked(deletedIDs, s)
s.mu.Unlock()
s.authKeys.mu.Unlock()
return out, nil

View file

@ -0,0 +1,83 @@
package memory
import (
"context"
"sync"
"time"
"telesrv/internal/domain"
)
type AuthDeliveryReportStore struct {
mu sync.Mutex
nextID int64
byFingerprint map[[32]byte]domain.AuthDeliveryReport
}
func NewAuthDeliveryReportStore() *AuthDeliveryReportStore {
return &AuthDeliveryReportStore{
nextID: 1, byFingerprint: make(map[[32]byte]domain.AuthDeliveryReport),
}
}
func (s *AuthDeliveryReportStore) CreateAuthDeliveryReport(_ context.Context, report domain.AuthDeliveryReport) (domain.AuthDeliveryReport, bool, error) {
if err := report.Validate(); err != nil {
return domain.AuthDeliveryReport{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if existing, ok := s.byFingerprint[report.Fingerprint]; ok {
return existing, false, nil
}
var hourly, phoneDaily int
hourAgo := report.CreatedAt.Add(-time.Hour)
dayAgo := report.CreatedAt.Add(-24 * time.Hour)
for _, existing := range s.byFingerprint {
if existing.CreatedAt.After(report.CreatedAt) {
continue
}
if existing.AuthKeyID == report.AuthKeyID && !existing.CreatedAt.Before(hourAgo) {
hourly++
}
if existing.PhoneHash == report.PhoneHash && !existing.CreatedAt.Before(dayAgo) {
phoneDaily++
}
}
if hourly >= domain.MaxAuthDeliveryReportsPerHour ||
phoneDaily >= domain.MaxAuthDeliveryReportsPerPhoneDay {
return domain.AuthDeliveryReport{}, false, domain.ErrAuthDeliveryRateLimited
}
report.ID = s.nextID
s.nextID++
s.byFingerprint[report.Fingerprint] = report
return report, true, nil
}
func (s *AuthDeliveryReportStore) Reports() []domain.AuthDeliveryReport {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.AuthDeliveryReport, 0, len(s.byFingerprint))
for _, report := range s.byFingerprint {
out = append(out, report)
}
return out
}
func (s *AuthDeliveryReportStore) DeleteExpiredAuthDeliveryReports(_ context.Context, olderThan time.Time, limit int) (int, error) {
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
return 0, domain.ErrAuthDeliveryReportInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
deleted := 0
for fingerprint, report := range s.byFingerprint {
if deleted >= limit {
break
}
if report.CreatedAt.Before(olderThan) {
delete(s.byFingerprint, fingerprint)
deleted++
}
}
return deleted, nil
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,851 @@
package memory
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"telesrv/internal/domain"
)
func botVerificationUserPeer(id int64) domain.Peer {
return domain.Peer{Type: domain.PeerTypeUser, ID: id}
}
func botVerificationChannelPeer(id int64) domain.Peer {
return domain.Peer{Type: domain.PeerTypeChannel, ID: id}
}
// botVerificationTestVerifier grants verifier status the way the admin edge does.
func botVerificationTestVerifier(t *testing.T, s *BotVerificationStore, botID, iconDocumentID int64) domain.BotVerifierSettings {
t.Helper()
settings, err := s.UpsertBotVerifierSettings(context.Background(), domain.BotVerifierSettings{
BotID: botID,
IconDocumentID: iconDocumentID,
CompanyName: fmt.Sprintf("Verifier %d", botID),
DefaultDescription: "verified by the test verifier",
CanModifyCustomDescription: true,
Enabled: true,
GrantedBy: "operator",
GrantReason: "test fixture",
})
if err != nil {
t.Fatalf("grant verifier %d: %v", botID, err)
}
return settings
}
// botVerificationTestRequest is an application that clears domain validation.
func botVerificationTestRequest(verifier, applicant int64, peer domain.Peer, username string) domain.CustomVerificationRequest {
return domain.CustomVerificationRequest{
VerifierBotID: verifier,
ApplicantUserID: applicant,
Peer: peer,
PeerTitle: "Target " + username,
PeerUsername: username,
Reason: "we run the official account for this brand",
RequestedDescription: "official brand account",
CorrelationID: fmt.Sprintf("corr-%d", peer.ID),
}
}
// TestBotVerificationIconCatalogueMemory covers the catalogue: an entry is keyed
// by document id, retiring one keeps it readable, and the listing pages newest
// first with the activeOnly filter honoured.
func TestBotVerificationIconCatalogueMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
first, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{
DocumentID: 5001, Name: " Blue check ", Active: true,
})
if err != nil {
t.Fatalf("upsert icon: %v", err)
}
if first.ID == 0 || first.Name != "Blue check" || !first.Active || first.OwnerBotID != 0 {
t.Fatalf("stored icon = %+v", first)
}
if first.CreatedAt.IsZero() || first.UpdatedAt.Before(first.CreatedAt) {
t.Fatalf("icon timestamps = %v / %v", first.CreatedAt, first.UpdatedAt)
}
second, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{
DocumentID: 5002, OwnerBotID: 777, Name: "Reserved", Active: true,
})
if err != nil {
t.Fatalf("upsert second icon: %v", err)
}
// document_id is the identity of an entry: the second upsert edits in place.
edited, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{
DocumentID: 5001, OwnerBotID: 42, Name: "Blue check v2", Active: true,
})
if err != nil {
t.Fatalf("re-upsert icon: %v", err)
}
if edited.ID != first.ID || edited.Name != "Blue check v2" || edited.OwnerBotID != 42 {
t.Fatalf("edited icon = %+v, want id %d", edited, first.ID)
}
if !edited.CreatedAt.Equal(first.CreatedAt) || !edited.UpdatedAt.After(first.UpdatedAt) {
t.Fatalf("edited timestamps = %v / %v", edited.CreatedAt, edited.UpdatedAt)
}
if _, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{DocumentID: 0, Name: "bad"}); !errors.Is(err, domain.ErrVerificationIconInvalid) {
t.Fatalf("upsert without document err = %v, want ErrVerificationIconInvalid", err)
}
if _, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{DocumentID: 9, Name: " "}); !errors.Is(err, domain.ErrVerificationIconInvalid) {
t.Fatalf("upsert without name err = %v, want ErrVerificationIconInvalid", err)
}
retired, err := s.SetVerificationIconActive(ctx, second.ID, false)
if err != nil {
t.Fatalf("retire icon: %v", err)
}
if retired.Active || retired.ID != second.ID {
t.Fatalf("retired icon = %+v", retired)
}
if _, err := s.SetVerificationIconActive(ctx, second.ID+1000, false); !errors.Is(err, domain.ErrVerificationIconNotFound) {
t.Fatalf("retire unknown err = %v, want ErrVerificationIconNotFound", err)
}
byDocument, err := s.VerificationIconByDocument(ctx, 5002)
if err != nil {
t.Fatalf("read icon by document: %v", err)
}
if byDocument.ID != second.ID || byDocument.Active {
t.Fatalf("icon by document = %+v", byDocument)
}
if _, err := s.VerificationIconByDocument(ctx, 999999); !errors.Is(err, domain.ErrVerificationIconNotFound) {
t.Fatalf("unknown document err = %v, want ErrVerificationIconNotFound", err)
}
byID, err := s.VerificationIcon(ctx, first.ID)
if err != nil || byID.DocumentID != 5001 {
t.Fatalf("read icon by id = %+v err=%v", byID, err)
}
if _, err := s.VerificationIcon(ctx, 0); !errors.Is(err, domain.ErrVerificationIconNotFound) {
t.Fatalf("icon id 0 err = %v, want ErrVerificationIconNotFound", err)
}
all, err := s.ListVerificationIcons(ctx, false, 0)
if err != nil {
t.Fatalf("list icons: %v", err)
}
if len(all) != 2 || all[0].ID != second.ID || all[1].ID != first.ID {
t.Fatalf("catalogue order = %+v, want newest first", all)
}
active, err := s.ListVerificationIcons(ctx, true, 0)
if err != nil {
t.Fatalf("list active icons: %v", err)
}
if len(active) != 1 || active[0].ID != first.ID {
t.Fatalf("active catalogue = %+v", active)
}
if page, err := s.ListVerificationIcons(ctx, false, 1); err != nil || len(page) != 1 {
t.Fatalf("icon page = %+v err=%v", page, err)
}
}
// TestBotVerifierSettingsLifecycleMemory covers verifier status: optimistic
// locking on version, the idempotent kill switch and the cascade that takes the
// marks with the verifier row.
func TestBotVerifierSettingsLifecycleMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
if _, err := s.BotVerifierSettings(ctx, 4242); !errors.Is(err, domain.ErrVerifierNotFound) {
t.Fatalf("unknown verifier err = %v, want ErrVerifierNotFound", err)
}
if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242, IconDocumentID: 7, CompanyName: "Acme", Version: 3,
}); !errors.Is(err, domain.ErrVerifierNotFound) {
t.Fatalf("versioned upsert of missing row err = %v, want ErrVerifierNotFound", err)
}
if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242, IconDocumentID: 0, CompanyName: "Acme",
}); !errors.Is(err, domain.ErrVerifierSettingsInvalid) {
t.Fatalf("iconless upsert err = %v, want ErrVerifierSettingsInvalid", err)
}
created := botVerificationTestVerifier(t, s, 4242, 5001)
if created.Version != 1 || !created.Enabled || created.CompanyName != "Verifier 4242" {
t.Fatalf("created verifier = %+v", created)
}
// Version 0 means "there is no row yet", so it loses against the stored row.
if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242, IconDocumentID: 5001, CompanyName: "Acme",
}); !errors.Is(err, domain.ErrCustomVerificationVersionConflict) {
t.Fatalf("re-create err = %v, want ErrCustomVerificationVersionConflict", err)
}
if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242, IconDocumentID: 5001, CompanyName: "Acme", Version: 99,
}); !errors.Is(err, domain.ErrCustomVerificationVersionConflict) {
t.Fatalf("stale upsert err = %v, want ErrCustomVerificationVersionConflict", err)
}
edited, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242,
IconDocumentID: 5002,
CompanyName: "Acme Media",
DefaultDescription: "checked by Acme",
Enabled: true,
Version: created.Version,
})
if err != nil {
t.Fatalf("edit verifier: %v", err)
}
if edited.Version != 2 || edited.IconDocumentID != 5002 || edited.CanModifyCustomDescription {
t.Fatalf("edited verifier = %+v", edited)
}
if !edited.CreatedAt.Equal(created.CreatedAt) || !edited.UpdatedAt.After(created.UpdatedAt) {
t.Fatalf("edited timestamps = %v / %v", edited.CreatedAt, edited.UpdatedAt)
}
disabled, err := s.SetBotVerifierEnabled(ctx, 4242, false)
if err != nil {
t.Fatalf("disable verifier: %v", err)
}
if disabled.Enabled || disabled.Version != edited.Version+1 {
t.Fatalf("disabled verifier = %+v", disabled)
}
again, err := s.SetBotVerifierEnabled(ctx, 4242, false)
if err != nil {
t.Fatalf("re-disable verifier: %v", err)
}
if again.Version != disabled.Version {
t.Fatalf("re-disable bumped version to %d", again.Version)
}
if _, err := s.SetBotVerifierEnabled(ctx, 777777, false); !errors.Is(err, domain.ErrVerifierNotFound) {
t.Fatalf("disable unknown err = %v, want ErrVerifierNotFound", err)
}
// A disabled verifier is still readable: the admin panel renders the switch.
stored, err := s.BotVerifierSettings(ctx, 4242)
if err != nil || stored.Enabled {
t.Fatalf("read disabled verifier = %+v err=%v", stored, err)
}
other := botVerificationTestVerifier(t, s, 1042, 5001)
batch, err := s.BotVerifierSettingsBatch(ctx, []int64{4242, 1042, 999999, 0})
if err != nil {
t.Fatalf("batch verifiers: %v", err)
}
if len(batch) != 2 || batch[4242].Enabled || !batch[1042].Enabled {
t.Fatalf("verifier batch = %+v", batch)
}
if _, absent := batch[999999]; absent {
t.Fatal("batch invented a verifier")
}
listed, err := s.ListBotVerifiers(ctx, false, 0)
if err != nil {
t.Fatalf("list verifiers: %v", err)
}
if len(listed) != 2 || listed[0].BotID != other.BotID || listed[1].BotID != 4242 {
t.Fatalf("verifier list = %+v, want bot id order", listed)
}
enabledOnly, err := s.ListBotVerifiers(ctx, true, 0)
if err != nil {
t.Fatalf("list enabled verifiers: %v", err)
}
if len(enabledOnly) != 1 || enabledOnly[0].BotID != other.BotID {
t.Fatalf("enabled verifier list = %+v", enabledOnly)
}
// Marks cascade with the verifier row; applications do not.
peer := botVerificationChannelPeer(9001)
if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: other.BotID, Peer: peer, Description: "cascade me",
}); err != nil {
t.Fatalf("grant before delete: %v", err)
}
req, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(other.BotID, 31337, peer, "CascadeChannel"))
if err != nil {
t.Fatalf("create request before delete: %v", err)
}
removed, err := s.DeleteBotVerifierSettings(ctx, other.BotID)
if err != nil || !removed {
t.Fatalf("delete verifier: removed=%v err=%v", removed, err)
}
if removed, err := s.DeleteBotVerifierSettings(ctx, other.BotID); err != nil || removed {
t.Fatalf("repeated delete: removed=%v err=%v", removed, err)
}
if _, err := s.CustomVerification(ctx, other.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("mark after cascade err = %v, want ErrCustomVerificationNotFound", err)
}
if count, err := s.CountCustomVerifications(ctx, other.BotID); err != nil || count != 0 {
t.Fatalf("mark count after cascade = %d err=%v", count, err)
}
if kept, err := s.CustomVerificationRequest(ctx, req.ID); err != nil || kept.ID != req.ID {
t.Fatalf("application after cascade = %+v err=%v", kept, err)
}
}
// TestCustomVerificationGrantAndProjectionMemory is the projection contract:
// exactly one mark exists per peer, a later verifier replaces the former one,
// a disabled verifier projects nothing while its row survives, and a repeated
// grant updates the mark in place.
func TestCustomVerificationGrantAndProjectionMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
alpha := botVerificationTestVerifier(t, s, 101, 5001)
beta := botVerificationTestVerifier(t, s, 102, 5002)
peer := botVerificationUserPeer(7001)
if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: 999, Peer: peer,
}); !errors.Is(err, domain.ErrVerifierNotFound) {
t.Fatalf("grant by non-verifier err = %v, want ErrVerifierNotFound", err)
}
if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: alpha.BotID, Peer: domain.Peer{Type: domain.PeerTypeCommunity, ID: 5},
}); !errors.Is(err, domain.ErrCustomVerificationTargetInvalid) {
t.Fatalf("grant on community err = %v, want ErrCustomVerificationTargetInvalid", err)
}
// The icon is denormalised from the verifier when the caller leaves it unset.
alphaMark, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: alpha.BotID, Peer: peer, Description: "checked by alpha",
GrantedByUserID: 555,
})
if err != nil || !created {
t.Fatalf("grant alpha mark: created=%v err=%v", created, err)
}
if alphaMark.IconDocumentID != alpha.IconDocumentID || alphaMark.Version != 1 {
t.Fatalf("alpha mark = %+v", alphaMark)
}
if alphaMark.CreatedAt.IsZero() || !alphaMark.UpdatedAt.Equal(alphaMark.CreatedAt) {
t.Fatalf("alpha mark timestamps = %v / %v", alphaMark.CreatedAt, alphaMark.UpdatedAt)
}
if got, err := s.PeerVerification(ctx, peer); err != nil || got.ID != alphaMark.ID {
t.Fatalf("projection with one mark = %+v err=%v", got, err)
}
betaMark, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: beta.BotID, Peer: peer, Description: "checked by beta",
})
if err != nil || !created {
t.Fatalf("grant beta mark: created=%v err=%v", created, err)
}
if betaMark.ID != alphaMark.ID || betaMark.Version != alphaMark.Version+1 {
t.Fatalf("replacement mark = %+v, want id %d v%d", betaMark, alphaMark.ID, alphaMark.Version+1)
}
if _, err := s.CustomVerification(ctx, alpha.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("replaced alpha mark err = %v, want ErrCustomVerificationNotFound", err)
}
if count, err := s.CountCustomVerifications(ctx, alpha.BotID); err != nil || count != 0 {
t.Fatalf("alpha mark count after replacement = %d err=%v", count, err)
}
// One peer has one wire-visible mark, irrespective of how often it is read.
for i := 0; i < 3; i++ {
got, err := s.PeerVerification(ctx, peer)
if err != nil {
t.Fatalf("projection after replacement: %v", err)
}
if got.ID != betaMark.ID || got.VerifierBotID != beta.BotID {
t.Fatalf("projection = %+v, want replacement mark %d", got, betaMark.ID)
}
if got.Projection().Icon != beta.IconDocumentID {
t.Fatalf("projected icon = %d, want %d", got.Projection().Icon, beta.IconDocumentID)
}
}
// Kill switch: disabling the current verifier hides the badge. The replaced
// alpha mark must not silently reappear.
if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, false); err != nil {
t.Fatalf("disable beta: %v", err)
}
if _, err := s.PeerVerification(ctx, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("projection after disabling beta err=%v, want ErrCustomVerificationNotFound", err)
}
if stored, err := s.CustomVerification(ctx, beta.BotID, peer); err != nil || stored.ID != betaMark.ID {
t.Fatalf("disabled verifier lost its mark: %+v err=%v", stored, err)
}
if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, true); err != nil {
t.Fatalf("re-enable beta: %v", err)
}
if got, err := s.PeerVerification(ctx, peer); err != nil || got.ID != betaMark.ID {
t.Fatalf("projection after re-enabling beta = %+v err=%v", got, err)
}
// Granting through alpha replaces beta's mark on the same peer.
regranted, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: alpha.BotID, Peer: peer, IconDocumentID: 5099,
Description: "checked by alpha, again",
})
if err != nil || !created {
t.Fatalf("re-grant alpha mark: created=%v err=%v", created, err)
}
if regranted.ID != betaMark.ID || regranted.Version != betaMark.Version+1 {
t.Fatalf("re-granted mark = %+v, want id %d v%d", regranted, betaMark.ID, betaMark.Version+1)
}
if regranted.IconDocumentID != 5099 || regranted.Description != "checked by alpha, again" {
t.Fatalf("re-granted payload = %+v", regranted)
}
if regranted.CreatedAt.Before(betaMark.CreatedAt) || !regranted.UpdatedAt.After(betaMark.UpdatedAt) {
t.Fatalf("re-granted timestamps = %v / %v", regranted.CreatedAt, regranted.UpdatedAt)
}
if count, err := s.CountCustomVerifications(ctx, alpha.BotID); err != nil || count != 1 {
t.Fatalf("alpha mark count = %d err=%v", count, err)
}
if got, err := s.PeerVerification(ctx, peer); err != nil || got.VerifierBotID != alpha.BotID {
t.Fatalf("projection after re-grant = %+v err=%v", got, err)
}
// The batch form resolves several peers at once, with the same rules.
second := botVerificationChannelPeer(7002)
third := botVerificationUserPeer(7003)
unmarked := botVerificationChannelPeer(7004)
secondMark, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: alpha.BotID, Peer: second, Description: "second",
})
if err != nil {
t.Fatalf("grant second: %v", err)
}
thirdMark, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: beta.BotID, Peer: third, Description: "third",
})
if err != nil {
t.Fatalf("grant third: %v", err)
}
batch, err := s.PeerVerificationBatch(ctx,
[]domain.Peer{peer, second, third, unmarked, peer, {Type: domain.PeerTypeUser, ID: 0}})
if err != nil {
t.Fatalf("batch projection: %v", err)
}
if len(batch) != 3 {
t.Fatalf("batch projection = %+v, want 3 peers", batch)
}
if batch[peer].ID != regranted.ID || batch[second].ID != secondMark.ID ||
batch[third].ID != thirdMark.ID {
t.Fatalf("batch projection picked %+v", batch)
}
if _, present := batch[unmarked]; present {
t.Fatal("batch projected an unmarked peer")
}
if empty, err := s.PeerVerificationBatch(ctx, nil); err != nil || len(empty) != 0 {
t.Fatalf("empty batch = %+v err=%v", empty, err)
}
// Disabling a verifier drops its peers from the batch too.
if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, false); err != nil {
t.Fatalf("disable beta again: %v", err)
}
batch, err = s.PeerVerificationBatch(ctx, []domain.Peer{peer, second, third})
if err != nil {
t.Fatalf("batch projection after disable: %v", err)
}
if len(batch) != 2 || batch[peer].ID != regranted.ID || batch[second].ID != secondMark.ID {
t.Fatalf("batch after disable = %+v", batch)
}
if _, present := batch[third]; present {
t.Fatal("disabled verifier still projects in the batch")
}
if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, true); err != nil {
t.Fatalf("re-enable beta again: %v", err)
}
// Listing and paging over the marks.
all, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{})
if err != nil || len(all) != 3 {
t.Fatalf("list marks = %+v err=%v", all, err)
}
if all[0].ID != thirdMark.ID {
t.Fatalf("mark list order = %+v, want newest first", all)
}
page, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{Limit: 2})
if err != nil || len(page) != 2 {
t.Fatalf("mark page = %+v err=%v", page, err)
}
next, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
Limit: 2, BeforeID: page[len(page)-1].ID,
})
if err != nil || len(next) != 1 || next[0].ID >= page[len(page)-1].ID {
t.Fatalf("mark keyset page = %+v err=%v", next, err)
}
mine, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
VerifierBotID: alpha.BotID,
})
if err != nil || len(mine) != 2 {
t.Fatalf("verifier-filtered marks = %+v err=%v", mine, err)
}
channels, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
PeerType: domain.PeerTypeChannel,
})
if err != nil || len(channels) != 1 || channels[0].Peer != second {
t.Fatalf("channel-filtered marks = %+v err=%v", channels, err)
}
byPeer, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
PeerType: peer.Type, PeerID: peer.ID,
})
if err != nil || len(byPeer) != 1 {
t.Fatalf("peer-filtered marks = %+v err=%v", byPeer, err)
}
byQuery, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
Query: fmt.Sprintf("%d", second.ID),
})
if err != nil || len(byQuery) != 1 || byQuery[0].ID != secondMark.ID {
t.Fatalf("numeric mark query = %+v err=%v", byQuery, err)
}
byText, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{Query: "AGAIN"})
if err != nil || len(byText) != 1 || byText[0].ID != regranted.ID {
t.Fatalf("text mark query = %+v err=%v", byText, err)
}
if _, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
PeerType: domain.PeerTypeFolder,
}); !errors.Is(err, domain.ErrCustomVerificationTargetInvalid) {
t.Fatalf("bad peer-type filter err = %v, want ErrCustomVerificationTargetInvalid", err)
}
// A replaced verifier cannot revoke the current mark.
revoked, err := s.RevokeCustomVerification(ctx, beta.BotID, peer)
if err != nil || revoked {
t.Fatalf("revoke replaced beta mark: revoked=%v err=%v", revoked, err)
}
if revoked, err := s.RevokeCustomVerification(ctx, beta.BotID, peer); err != nil || revoked {
t.Fatalf("repeated revoke: revoked=%v err=%v", revoked, err)
}
if got, err := s.PeerVerification(ctx, peer); err != nil || got.ID != regranted.ID {
t.Fatalf("projection after rejected revoke = %+v err=%v", got, err)
}
if revoked, err := s.RevokeCustomVerification(ctx, alpha.BotID, peer); err != nil || !revoked {
t.Fatalf("revoke alpha mark: revoked=%v err=%v", revoked, err)
}
if _, err := s.RevokeCustomVerification(ctx, 0, peer); !errors.Is(err, domain.ErrCustomVerificationTargetInvalid) {
t.Fatalf("revoke without verifier err = %v, want ErrCustomVerificationTargetInvalid", err)
}
}
// TestCustomVerificationLimitMemory pins the per-verifier bound: a new mark is
// refused at the limit while an existing one can still be re-described.
func TestCustomVerificationLimitMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
verifier := botVerificationTestVerifier(t, s, 303, 5001)
for i := 1; i <= domain.MaxCustomVerificationsPerVerifier; i++ {
if _, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: verifier.BotID, Peer: botVerificationChannelPeer(int64(i)),
}); err != nil || !created {
t.Fatalf("grant %d: created=%v err=%v", i, created, err)
}
}
if count, err := s.CountCustomVerifications(ctx, verifier.BotID); err != nil ||
count != domain.MaxCustomVerificationsPerVerifier {
t.Fatalf("mark count = %d err=%v", count, err)
}
if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: verifier.BotID, Peer: botVerificationUserPeer(424242),
}); !errors.Is(err, domain.ErrCustomVerificationLimit) {
t.Fatalf("grant past the limit err = %v, want ErrCustomVerificationLimit", err)
}
// The bound is on creating marks, not on editing them.
if _, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: verifier.BotID, Peer: botVerificationChannelPeer(7),
Description: "still editable at the limit",
}); err != nil || created {
t.Fatalf("re-grant at the limit: created=%v err=%v", created, err)
}
// Freeing one slot lets the next grant through.
if revoked, err := s.RevokeCustomVerification(ctx, verifier.BotID,
botVerificationChannelPeer(1)); err != nil || !revoked {
t.Fatalf("free a slot: revoked=%v err=%v", revoked, err)
}
if _, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: verifier.BotID, Peer: botVerificationUserPeer(424242),
}); err != nil || !created {
t.Fatalf("grant into the freed slot: created=%v err=%v", created, err)
}
}
// TestCustomVerificationRequestQueueMemory covers the application queue: one
// pending application per (verifier, peer), the decision status machine, and the
// transaction that keeps an approved application and its mark together.
func TestCustomVerificationRequestQueueMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
verifier := botVerificationTestVerifier(t, s, 501, 5001)
peer := botVerificationChannelPeer(8001)
applicant := int64(6001)
grant := func(ctx context.Context, req domain.CustomVerificationRequest) error {
_, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: req.VerifierBotID,
Peer: req.Peer,
Description: req.RequestedDescription,
GrantedByUserID: req.ApplicantUserID,
})
return err
}
revoke := func(ctx context.Context, req domain.CustomVerificationRequest) error {
_, err := s.RevokeCustomVerification(ctx, req.VerifierBotID, req.Peer)
return err
}
filed, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(verifier.BotID, applicant, peer, "AcmeNews"))
if err != nil {
t.Fatalf("file application: %v", err)
}
if filed.Status != domain.CustomVerificationPending || filed.Version != 1 {
t.Fatalf("filed application = %s v%d", filed.Status, filed.Version)
}
if !filed.ApprovedAt.IsZero() || !filed.RejectedAt.IsZero() || filed.DecidedBy != "" {
t.Fatalf("filed application carries a decision: %+v", filed)
}
// custom_verification_requests_pending_idx: one live application per pair.
if _, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(verifier.BotID, applicant, peer, "AcmeNews")); !errors.Is(err, domain.ErrCustomVerificationRequestExists) {
t.Fatalf("duplicate pending err = %v, want ErrCustomVerificationRequestExists", err)
}
if _, err := s.CreateCustomVerificationRequest(ctx, domain.CustomVerificationRequest{
VerifierBotID: verifier.BotID, ApplicantUserID: applicant, Peer: peer,
Status: domain.CustomVerificationApproved,
}); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("pre-decided application err = %v, want ErrCustomVerificationRequestInvalid", err)
}
// The SQL byte bound reserves the worst-case UTF-8 width for the domain's
// rune limit, so valid multi-byte text must behave the same in both stores.
wideStore := NewBotVerificationStore()
wideVerifier := botVerificationTestVerifier(t, wideStore, 909, 5001)
wide := botVerificationTestRequest(wideVerifier.BotID, applicant, botVerificationUserPeer(8009), "Wide")
wide.Reason = strings.Repeat("é", domain.MaxCustomVerificationReasonLength-1)
if _, err := wideStore.CreateCustomVerificationRequest(ctx, wide); err != nil {
t.Fatalf("valid multi-byte reason: %v", err)
}
pending, err := s.PendingCustomVerificationRequest(ctx, verifier.BotID, peer)
if err != nil || pending.ID != filed.ID {
t.Fatalf("pending application = %+v err=%v", pending, err)
}
// A failing callback rolls the whole decision back, including what the
// callback itself wrote before it failed.
boom := errors.New("apply exploded")
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version,
domain.CustomVerificationApproved, "operator", "looks good", "",
func(ctx context.Context, req domain.CustomVerificationRequest) error {
if err := grant(ctx, req); err != nil {
return err
}
return boom
}); !errors.Is(err, boom) {
t.Fatalf("failing apply err = %v, want the callback error", err)
}
rolledBack, err := s.CustomVerificationRequest(ctx, filed.ID)
if err != nil {
t.Fatalf("read after rollback: %v", err)
}
if rolledBack.Status != domain.CustomVerificationPending || rolledBack.Version != filed.Version {
t.Fatalf("application after rollback = %s v%d, want pending v%d",
rolledBack.Status, rolledBack.Version, filed.Version)
}
if !rolledBack.ApprovedAt.IsZero() || rolledBack.DecidedBy != "" {
t.Fatalf("application after rollback carries a decision: %+v", rolledBack)
}
if _, err := s.CustomVerification(ctx, verifier.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("mark after rollback err = %v, want ErrCustomVerificationNotFound", err)
}
if _, err := s.PeerVerification(ctx, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("projection after rollback err = %v, want ErrCustomVerificationNotFound", err)
}
// Approving requires a callback: there is no "approved, mark later".
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version,
domain.CustomVerificationApproved, "operator", "", "", nil); err == nil {
t.Fatal("approve without a callback succeeded")
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version+7,
domain.CustomVerificationApproved, "operator", "", "", grant); !errors.Is(err, domain.ErrCustomVerificationVersionConflict) {
t.Fatalf("stale decision err = %v, want ErrCustomVerificationVersionConflict", err)
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version,
domain.CustomVerificationPending, "operator", "", "", nil); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("decision back to pending err = %v, want ErrCustomVerificationRequestInvalid", err)
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID+9000, filed.Version,
domain.CustomVerificationApproved, "operator", "", "", grant); !errors.Is(err, domain.ErrCustomVerificationRequestNotFound) {
t.Fatalf("decision on unknown application err = %v, want ErrCustomVerificationRequestNotFound", err)
}
approved, changed, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version,
domain.CustomVerificationApproved, "operator", "brand confirmed", "ticket 12", grant)
if err != nil || !changed {
t.Fatalf("approve: changed=%v err=%v", changed, err)
}
if approved.Status != domain.CustomVerificationApproved || approved.Version != filed.Version+1 {
t.Fatalf("approved application = %s v%d", approved.Status, approved.Version)
}
if approved.ApprovedAt.IsZero() || !approved.RejectedAt.IsZero() {
t.Fatalf("approved stamps = %v / %v", approved.ApprovedAt, approved.RejectedAt)
}
if approved.DecidedBy != "operator" || approved.DecisionReason != "brand confirmed" ||
approved.InternalNote != "ticket 12" {
t.Fatalf("approved decision metadata = %+v", approved)
}
mark, err := s.PeerVerification(ctx, peer)
if err != nil {
t.Fatalf("projection after approve: %v", err)
}
if mark.VerifierBotID != verifier.BotID || mark.Description != filed.RequestedDescription ||
mark.IconDocumentID != verifier.IconDocumentID {
t.Fatalf("mark after approve = %+v", mark)
}
if _, err := s.PendingCustomVerificationRequest(ctx, verifier.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationRequestNotFound) {
t.Fatalf("pending after approve err = %v, want ErrCustomVerificationRequestNotFound", err)
}
// Re-issuing the decision that already holds moves nothing and does not apply
// the callback a second time.
repeat, changed, err := s.DecideCustomVerificationRequest(ctx, approved.ID, approved.Version,
domain.CustomVerificationApproved, "someone else", "again", "", func(context.Context, domain.CustomVerificationRequest) error {
t.Fatal("apply ran for a decision that already held")
return nil
})
if err != nil || changed {
t.Fatalf("repeated approve: changed=%v err=%v", changed, err)
}
if repeat.Version != approved.Version || repeat.DecidedBy != "operator" {
t.Fatalf("repeated approve mutated the row: %+v", repeat)
}
// approved -> rejected is not in the status machine; approved -> revoked is.
if _, _, err := s.DecideCustomVerificationRequest(ctx, approved.ID, approved.Version,
domain.CustomVerificationRejected, "operator", "changed my mind", "", nil); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("approved -> rejected err = %v, want ErrCustomVerificationRequestInvalid", err)
}
revoked, changed, err := s.DecideCustomVerificationRequest(ctx, approved.ID, approved.Version,
domain.CustomVerificationRevoked, "operator", "brand asked us to", "", revoke)
if err != nil || !changed {
t.Fatalf("revoke: changed=%v err=%v", changed, err)
}
if revoked.Status != domain.CustomVerificationRevoked || revoked.Version != approved.Version+1 {
t.Fatalf("revoked application = %s v%d", revoked.Status, revoked.Version)
}
// The stamps are paired with the status, so leaving approved clears approved_at.
if !revoked.ApprovedAt.IsZero() || !revoked.RejectedAt.IsZero() {
t.Fatalf("revoked stamps = %v / %v", revoked.ApprovedAt, revoked.RejectedAt)
}
if _, err := s.PeerVerification(ctx, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("projection after revoke err = %v, want ErrCustomVerificationNotFound", err)
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, revoked.ID, revoked.Version,
domain.CustomVerificationApproved, "operator", "", "", grant); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("revoked -> approved err = %v, want ErrCustomVerificationRequestInvalid", err)
}
// A rejection needs a reason, and the domain is what says so.
other := botVerificationUserPeer(8002)
second, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(verifier.BotID, applicant, other, "AcmeCEO"))
if err != nil {
t.Fatalf("file second application: %v", err)
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, second.ID, second.Version,
domain.CustomVerificationRejected, "operator", " ", "", nil); !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("reject without a reason err = %v, want ErrVerificationReasonRequired", err)
}
stillPending, err := s.CustomVerificationRequest(ctx, second.ID)
if err != nil || stillPending.Status != domain.CustomVerificationPending ||
stillPending.Version != second.Version {
t.Fatalf("application after refused rejection = %+v err=%v", stillPending, err)
}
rejected, changed, err := s.DecideCustomVerificationRequest(ctx, second.ID, second.Version,
domain.CustomVerificationRejected, "operator", "not a public figure", "", nil)
if err != nil || !changed {
t.Fatalf("reject: changed=%v err=%v", changed, err)
}
if rejected.Status != domain.CustomVerificationRejected || rejected.RejectedAt.IsZero() ||
!rejected.ApprovedAt.IsZero() {
t.Fatalf("rejected application = %+v", rejected)
}
if _, err := s.CustomVerification(ctx, verifier.BotID, other); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("rejection granted a mark: %v", err)
}
// A decided pair is free again: history keeps the rejection.
reapplied, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(verifier.BotID, applicant, other, "AcmeCEO"))
if err != nil {
t.Fatalf("re-apply after rejection: %v", err)
}
counts, err := s.CustomVerificationRequestCounts(ctx)
if err != nil {
t.Fatalf("queue counts: %v", err)
}
if counts[domain.CustomVerificationPending] != 1 ||
counts[domain.CustomVerificationRejected] != 1 ||
counts[domain.CustomVerificationRevoked] != 1 {
t.Fatalf("queue counts = %+v", counts)
}
if _, present := counts[domain.CustomVerificationApproved]; present {
t.Fatalf("queue counts invented an approved application: %+v", counts)
}
listed, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{})
if err != nil || len(listed) != 3 {
t.Fatalf("queue list = %+v err=%v", listed, err)
}
if listed[0].ID != reapplied.ID {
t.Fatalf("queue order = %+v, want newest first", listed)
}
pendingOnly, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Statuses: []domain.CustomVerificationRequestStatus{domain.CustomVerificationPending},
})
if err != nil || len(pendingOnly) != 1 || pendingOnly[0].ID != reapplied.ID {
t.Fatalf("pending queue = %+v err=%v", pendingOnly, err)
}
page, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{Limit: 2})
if err != nil || len(page) != 2 {
t.Fatalf("queue page = %+v err=%v", page, err)
}
next, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Limit: 2, BeforeID: page[len(page)-1].ID,
})
if err != nil || len(next) != 1 || next[0].ID >= page[len(page)-1].ID {
t.Fatalf("queue keyset page = %+v err=%v", next, err)
}
byUsername, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Query: "@acmec",
})
if err != nil || len(byUsername) != 2 {
t.Fatalf("username query = %+v err=%v", byUsername, err)
}
byPeerID, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Query: fmt.Sprintf("%d", peer.ID),
})
if err != nil || len(byPeerID) != 1 || byPeerID[0].ID != revoked.ID {
t.Fatalf("numeric query = %+v err=%v", byPeerID, err)
}
channelsOnly, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
PeerType: domain.PeerTypeChannel, VerifierBotID: verifier.BotID,
})
if err != nil || len(channelsOnly) != 1 || channelsOnly[0].ID != revoked.ID {
t.Fatalf("channel queue = %+v err=%v", channelsOnly, err)
}
if _, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Statuses: []domain.CustomVerificationRequestStatus{"nonsense"},
}); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("bad status filter err = %v, want ErrCustomVerificationRequestInvalid", err)
}
history, err := s.CustomVerificationRequestsForApplicant(ctx, applicant, 0)
if err != nil || len(history) != 3 || history[0].ID != reapplied.ID {
t.Fatalf("applicant history = %+v err=%v", history, err)
}
if empty, err := s.CustomVerificationRequestsForApplicant(ctx, applicant+1, 0); err != nil ||
len(empty) != 0 {
t.Fatalf("other applicant history = %+v err=%v", empty, err)
}
if _, err := s.CustomVerificationRequestsForApplicant(ctx, 0, 0); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("history for applicant 0 err = %v, want ErrCustomVerificationRequestInvalid", err)
}
if _, err := s.CustomVerificationRequest(ctx, reapplied.ID+9000); !errors.Is(err, domain.ErrCustomVerificationRequestNotFound) {
t.Fatalf("unknown application err = %v, want ErrCustomVerificationRequestNotFound", err)
}
}

View file

@ -229,8 +229,15 @@ func (s *ChannelStore) GetChannelByID(_ context.Context, channelID int64) (domai
return cloneChannel(channel), nil
}
func publicPreviewableChannel(channel domain.Channel) bool {
return publicSearchableChannel(channel)
func (s *ChannelStore) publicPreviewableChannelLocked(channel domain.Channel) bool {
hasActiveUsername := strings.TrimSpace(channel.Username) != ""
if !hasActiveUsername && s.usernameRegistry != nil {
hasActiveUsername = s.usernameRegistry.peerHasActiveCollectibleUsername(domain.Peer{
Type: domain.PeerTypeChannel,
ID: channel.ID,
})
}
return publicSearchableChannel(channel) && hasActiveUsername
}
func minInt(a, b int) int {

View file

@ -95,8 +95,8 @@ func (s *ChannelStore) ListChannelDialogs(_ context.Context, viewerUserID int64,
out.Dialogs = append(out.Dialogs, dialog)
channel := s.channels[dialog.Peer.ID]
out.Channels = append(out.Channels, channel)
if msg, ok := s.findMessageLocked(dialog.Peer.ID, dialog.TopMessage); ok && !msg.Deleted {
out.Messages = append(out.Messages, cloneChannelMessage(msg))
if msg, ok := s.channelMessageForMemberLocked(viewerUserID, dialog.Peer.ID, dialog.TopMessage); ok {
out.Messages = append(out.Messages, msg)
}
}
// 与 PG 同因:getDialogs top message 按 viewer 补未读提及标志。
@ -151,8 +151,8 @@ func (s *ChannelStore) GetChannelDialogs(_ context.Context, viewerUserID int64,
out.Channels = append(out.Channels, cloneChannel(parent))
}
}
if msg, ok := s.findMessageLocked(channelID, dialog.TopMessage); ok && !msg.Deleted {
out.Messages = append(out.Messages, cloneChannelMessage(msg))
if msg, ok := s.channelMessageForMemberLocked(viewerUserID, channelID, dialog.TopMessage); ok {
out.Messages = append(out.Messages, msg)
}
}
out.Count = len(out.Dialogs)
@ -470,36 +470,47 @@ func channelDialogToDialog(dialog domain.ChannelDialog, channelPts int, memberSt
return domain.Dialog{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
// 非成员预览(publicPreviewMember/被踢)须标记 ChannelLeft,客户端据此把频道渲染为只读 left 预览。
ChannelLeft: memberStatus == domain.ChannelMemberLeft,
FolderID: dialog.FolderID,
TopMessage: dialog.TopMessageID,
TopMessageDate: dialog.TopMessageDate,
ReadInboxMaxID: dialog.ReadInboxMaxID,
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount,
UnreadMentions: dialog.UnreadMentions,
UnreadReactions: dialog.UnreadReactions,
Pinned: dialog.Pinned,
PinnedOrder: dialog.PinnedOrder,
UnreadMark: dialog.UnreadMark,
ViewForumAsMessages: dialog.ViewForumAsMessages,
HasScheduled: dialog.HasScheduled,
Pts: channelPts,
ChannelLeft: memberStatus == domain.ChannelMemberLeft,
FolderID: dialog.FolderID,
TopMessage: dialog.TopMessageID,
TopMessageDate: dialog.TopMessageDate,
HistoryClearAnchorID: dialog.HistoryClearAnchorID,
HistoryClearAnchorDate: dialog.HistoryClearAnchorDate,
ReadInboxMaxID: dialog.ReadInboxMaxID,
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount,
UnreadMentions: dialog.UnreadMentions,
UnreadReactions: dialog.UnreadReactions,
Pinned: dialog.Pinned,
PinnedOrder: dialog.PinnedOrder,
UnreadMark: dialog.UnreadMark,
ViewForumAsMessages: dialog.ViewForumAsMessages,
HasScheduled: dialog.HasScheduled,
Pts: channelPts,
}
}
func previewChannelDialog(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog {
topMessageID := channel.TopMessageID
if topMessageID <= member.AvailableMinID {
topMessageID = 0
topMessageID = member.HistoryClearAnchorID
if topMessageID != member.AvailableMinID {
topMessageID = 0
}
}
topMessageDate := channel.Date
if topMessageID > 0 && topMessageID == member.HistoryClearAnchorID {
topMessageDate = member.HistoryClearAnchorDate
}
return domain.ChannelDialog{
UserID: userID,
ChannelID: channel.ID,
TopMessageID: topMessageID,
TopMessageDate: channel.Date,
ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID),
ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID),
UserID: userID,
ChannelID: channel.ID,
TopMessageID: topMessageID,
TopMessageDate: topMessageDate,
HistoryClearAnchorID: member.HistoryClearAnchorID,
HistoryClearAnchorDate: member.HistoryClearAnchorDate,
ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID),
ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID),
}
}

View file

@ -83,6 +83,13 @@ func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int6
return domain.PublicChannelSearchResult{}, nil
}
s.mu.RLock()
registry := s.usernameRegistry
s.mu.RUnlock()
var usernameMatches map[int64]int
if registry != nil {
usernameMatches = registry.activeUsernameMatches(query, domain.PeerTypeChannel)
}
s.mu.RLock()
defer s.mu.RUnlock()
type item struct {
@ -92,6 +99,11 @@ func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int6
items := make([]item, 0, limit)
for channelID, channel := range s.channels {
rank, ok := publicChannelSearchRank(channel, query)
if usernameRank, matched := usernameMatches[channelID]; matched &&
!channel.Deleted && (channel.Broadcast || channel.Megagroup) &&
(!ok || usernameRank < rank) {
rank, ok = usernameRank, true
}
if !ok {
continue
}
@ -325,9 +337,19 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID
if !ok || member.Status != domain.ChannelMemberActive {
continue
}
item := domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts}
checkpoint := s.channelUpdateCheckpointLocked(channelID, channel)
if checkpoint.LatestEventDate > sinceDate {
out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts})
item.ChannelUpdatesDirty = true
}
if clearDate := s.historyClearDates[channelID][userID]; clearDate >= sinceDate &&
member.HistoryClearAnchorID > 0 &&
member.HistoryClearAnchorID == member.AvailableMinID {
item.AvailableMinID = member.AvailableMinID
item.HistoryClearDate = clearDate
}
if item.ChannelUpdatesDirty || item.AvailableMinID > 0 {
out = append(out, item)
}
}
for channelID, channel := range s.channels {
@ -344,7 +366,11 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID
}
}
if !found {
out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts})
out = append(out, domain.DirtyChannel{
ChannelID: channelID,
Pts: channel.Pts,
ChannelUpdatesDirty: true,
})
}
}
}
@ -426,12 +452,25 @@ func (s *ChannelStore) channelForViewerLocked(userID, channelID int64) (domain.C
return channel, syntheticMonoforumUserMember(channel, userID), true, nil
}
}
if !publicPreviewableChannel(channel) {
if !s.publicPreviewableChannelLocked(channel) {
return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate
}
return channel, publicPreviewMember(channel, userID, existing, found), true, nil
}
// channelMessageVisibleToViewerLocked applies the message-level half of synthetic monoforum
// access. The channel shell is visible without a channel_members row, but a subscriber may only
// address messages in saved_peer=self; managers may address every subscriber sub-dialog.
func channelMessageVisibleToViewerLocked(channel domain.Channel, member domain.ChannelMember, viewerUserID int64, msg domain.ChannelMessage) bool {
if !channel.Monoforum {
return true
}
if member.CanManageDirectMessages() {
return true
}
return msg.SavedPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: viewerUserID})
}
func (s *ChannelStore) dialogForUserLocked(userID int64, channel domain.Channel) domain.ChannelDialog {
return s.dialogForMemberLocked(userID, channel, s.members[channel.ID][userID])
}
@ -441,12 +480,16 @@ func (s *ChannelStore) dialogForMemberLocked(userID int64, channel domain.Channe
dialog.UserID = userID
dialog.ChannelID = channel.ID
dialog.TopMessageID = s.visibleTopMessageIDForMemberLocked(channel, member)
dialog.HistoryClearAnchorID = member.HistoryClearAnchorID
dialog.HistoryClearAnchorDate = member.HistoryClearAnchorDate
// TopMessageDate 必须从可见 top 消息派生(不能继承空缓存的 0),否则会话排序/分页与预览
// dialog 的日期全错。与 postgres GetChannelDialogs 用 getChannelMessage 设 date 对齐。
dialog.TopMessageDate = 0
if dialog.TopMessageID > 0 {
if top, ok := s.findMessageLocked(channel.ID, dialog.TopMessageID); ok {
if top, ok := s.channelMessageForMemberLocked(userID, channel.ID, dialog.TopMessageID); ok {
dialog.TopMessageDate = top.Date
} else if dialog.TopMessageID == member.HistoryClearAnchorID {
dialog.TopMessageDate = member.HistoryClearAnchorDate
}
}
if member.ReadInboxMaxID > dialog.ReadInboxMaxID {
@ -556,8 +599,7 @@ func recommendableChannel(channel domain.Channel) bool {
func publicSearchableChannel(channel domain.Channel) bool {
return !channel.Deleted &&
(channel.Broadcast || channel.Megagroup) &&
strings.TrimSpace(channel.Username) != ""
(channel.Broadcast || channel.Megagroup)
}
func channelRoleOrder(role domain.ChannelMemberRole) int {

View file

@ -892,6 +892,42 @@ func (s *ChannelStore) FilterActiveChannelMemberIDs(_ context.Context, channelID
return out, nil
}
func (s *ChannelStore) FilterChannelMessageAudienceIDs(_ context.Context, channelID int64, userIDs []int64) ([]int64, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if channelID == 0 || len(userIDs) == 0 {
return nil, nil
}
channel, ok := s.channels[channelID]
if !ok || channel.Deleted {
return nil, nil
}
public := s.publicPreviewableChannelLocked(channel)
members := s.members[channelID]
out := make([]int64, 0, len(userIDs))
seen := make(map[int64]struct{}, len(userIDs))
for _, userID := range userIDs {
if userID == 0 {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
member, found := members[userID]
if member.BannedRights.ViewMessages ||
member.Status == domain.ChannelMemberKicked ||
member.Status == domain.ChannelMemberBanned {
continue
}
if member.Status == domain.ChannelMemberActive || public && (!found || member.Status == domain.ChannelMemberLeft) {
out = append(out, userID)
}
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out, nil
}
func (s *ChannelStore) ListActiveChannelMembers(_ context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error) {
s.mu.RLock()
defer s.mu.RUnlock()
@ -1071,6 +1107,8 @@ func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.Chan
}
member.AvailableMinID = 0
member.AvailableMinPts = 0
member.HistoryClearAnchorID = 0
member.HistoryClearAnchorDate = 0
member.ReadInboxMaxID = mono.TopMessageID
member.ReadOutboxMaxID = mono.TopMessageID
member.UnreadMark = false

View file

@ -72,6 +72,12 @@ func (s *ChannelStore) DeleteChannelHistory(_ context.Context, req domain.Delete
if err != nil {
return domain.DeleteChannelHistoryResult{}, err
}
if req.Date <= 0 {
req.Date = channel.Date
if req.Date <= 0 {
req.Date = 1
}
}
maxID := req.MaxID
if maxID <= 0 || maxID > channel.TopMessageID {
maxID = channel.TopMessageID
@ -79,6 +85,22 @@ func (s *ChannelStore) DeleteChannelHistory(_ context.Context, req domain.Delete
member := s.members[req.ChannelID][req.UserID]
if !req.ForEveryone {
appliedMinID := maxInt(member.AvailableMinID, maxID)
changed := appliedMinID > member.AvailableMinID
if changed {
anchorDate := req.Date
if msg, ok := s.findMessageLocked(req.ChannelID, appliedMinID); ok && msg.Date > 0 {
anchorDate = msg.Date
}
if anchorDate <= 0 {
anchorDate = channel.Date
}
member.HistoryClearAnchorID = appliedMinID
member.HistoryClearAnchorDate = anchorDate
if s.historyClearDates[req.ChannelID] == nil {
s.historyClearDates[req.ChannelID] = make(map[int64]int)
}
s.historyClearDates[req.ChannelID][req.UserID] = req.Date
}
member.AvailableMinID = appliedMinID
member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, appliedMinID)
member.UnreadMark = false
@ -92,7 +114,11 @@ func (s *ChannelStore) DeleteChannelHistory(_ context.Context, req domain.Delete
s.dialogs[req.UserID] = make(map[int64]domain.ChannelDialog)
}
s.dialogs[req.UserID][req.ChannelID] = s.dialogForUserLocked(req.UserID, channel)
return domain.DeleteChannelHistoryResult{Channel: channel, AvailableMinID: appliedMinID}, nil
return domain.DeleteChannelHistoryResult{
Channel: channel,
AvailableMinID: appliedMinID,
AvailableMinChanged: changed,
}, nil
}
if !canDeleteAnyChannelMessage(member) {
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired

View file

@ -25,8 +25,16 @@ func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64,
query := strings.ToLower(strings.TrimSpace(filter.Query))
matched := make([]domain.ChannelMessage, 0, len(items))
monoforumUserView := channel.Monoforum && !member.CanManageDirectMessages()
anchorID := 0
if filter.IncludeHistoryClearAnchor &&
member.HistoryClearAnchorID > 0 &&
member.HistoryClearAnchorID == member.AvailableMinID {
anchorID = member.HistoryClearAnchorID
}
anchorSeen := false
for _, msg := range items {
if msg.Deleted {
isAnchor := anchorID > 0 && msg.ID == anchorID
if msg.Deleted && !isAnchor {
continue
}
if channel.Monoforum {
@ -37,9 +45,18 @@ func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64,
continue
}
}
if msg.ID <= member.AvailableMinID {
if msg.ID < member.AvailableMinID || (msg.ID == member.AvailableMinID && !isAnchor) {
continue
}
if isAnchor {
msg = domain.ProjectChannelHistoryClearMessage(
msg,
filter.ChannelID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
)
anchorSeen = true
}
if filter.PinnedOnly && !msg.Pinned {
continue
}
@ -66,6 +83,38 @@ func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64,
}
matched = append(matched, msg)
}
if anchorID > 0 && !anchorSeen {
msg := domain.ProjectChannelHistoryClearMessage(
domain.ChannelMessage{},
filter.ChannelID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
)
if (filter.MinDate <= 0 || msg.Date > filter.MinDate) &&
(filter.MaxDate <= 0 || msg.Date < filter.MaxDate) &&
(filter.MaxID <= 0 || msg.ID <= filter.MaxID) &&
(filter.MinID <= 0 || msg.ID > filter.MinID) &&
!filter.PinnedOnly &&
!filter.MusicOnly &&
query == "" &&
filter.SenderUserID == 0 {
matched = append(matched, msg)
}
}
extraChannels := []domain.Channel(nil)
if channel.Monoforum && channel.LinkedMonoforumID != 0 {
if parent, ok := s.channels[channel.LinkedMonoforumID]; ok && !parent.Deleted {
extraChannels = append(extraChannels, cloneChannel(parent))
}
}
if filter.CountOnly {
return domain.ChannelHistory{
Channel: channel,
Self: member,
Channels: extraChannels,
Count: len(matched),
}, nil
}
// add_offset 决定加载方向(对齐 postgres ListChannelHistory):
// >= 0 backward:锚点更旧方向(不含锚点),先跳过 add_offset 条
// < 0 且 +limit>0 around:以锚点为中心,向更新取 -add_offset 条 + 向更旧(含锚点)取 limit+add_offset 条
@ -160,14 +209,12 @@ func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64,
if hasMoreOlder {
count = len(out) + 1
}
if filter.NeedTotalCount {
count = len(matched)
}
s.populateChannelMessageRepliesLocked(viewerUserID, filter.ChannelID, out)
s.populateChannelMessageReactionsLocked(viewerUserID, channel, out)
extraChannels := []domain.Channel(nil)
if channel.Monoforum && channel.LinkedMonoforumID != 0 {
if parent, ok := s.channels[channel.LinkedMonoforumID]; ok && !parent.Deleted {
extraChannels = append(extraChannels, cloneChannel(parent))
}
}
projectMemoryChannelHistoryClearMessages(channel.ID, member, out)
return domain.ChannelHistory{
Channel: channel,
Self: member,
@ -213,7 +260,7 @@ func (s *ChannelStore) SearchJoinedMessages(_ context.Context, viewerUserID int6
}
member, ok := s.members[channelID][viewerUserID]
joined := ok && member.Status == domain.ChannelMemberActive && !member.BannedRights.ViewMessages
publicPreview := req.AllowPublicPreview && publicPreviewableChannel(channel) &&
publicPreview := req.AllowPublicPreview && s.publicPreviewableChannelLocked(channel) &&
(!ok || member.Status != domain.ChannelMemberKicked && !member.BannedRights.ViewMessages)
if !joined && !publicPreview {
continue
@ -307,17 +354,62 @@ func (s *ChannelStore) GetChannelMessages(_ context.Context, viewerUserID, chann
if _, ok := wanted[msg.ID]; !ok {
continue
}
if msg.ID == member.HistoryClearAnchorID &&
member.HistoryClearAnchorID == member.AvailableMinID {
messages = append(messages, domain.ProjectChannelHistoryClearMessage(
msg,
channelID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
))
delete(wanted, msg.ID)
continue
}
if msg.Deleted || msg.ID <= member.AvailableMinID {
continue
}
if !channelMessageVisibleToViewerLocked(channel, member, viewerUserID, msg) {
continue
}
messages = append(messages, cloneChannelMessage(msg))
delete(wanted, msg.ID)
}
if member.HistoryClearAnchorID > 0 &&
member.HistoryClearAnchorID == member.AvailableMinID {
if _, ok := wanted[member.HistoryClearAnchorID]; ok {
messages = append(messages, domain.ProjectChannelHistoryClearMessage(
domain.ChannelMessage{},
channelID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
))
}
}
sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID })
s.populateChannelMessageRepliesLocked(viewerUserID, channelID, messages)
s.populateChannelMessageReactionsLocked(viewerUserID, channel, messages)
projectMemoryChannelHistoryClearMessages(channelID, member, messages)
return domain.ChannelHistory{Channel: channel, Self: member, Messages: messages, Count: len(messages)}, nil
}
func projectMemoryChannelHistoryClearMessages(channelID int64, member domain.ChannelMember, messages []domain.ChannelMessage) {
if member.HistoryClearAnchorID <= 0 ||
member.HistoryClearAnchorID != member.AvailableMinID {
return
}
for i := range messages {
if messages[i].ID != member.HistoryClearAnchorID {
continue
}
messages[i] = domain.ProjectChannelHistoryClearMessage(
messages[i],
channelID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
)
}
}
func (s *ChannelStore) ListStoryMessageForwards(_ context.Context, req domain.StoryMessageForwardListRequest) (domain.StoryMessageForwardList, error) {
if req.ViewerUserID == 0 || req.Owner.ID == 0 || req.StoryID <= 0 || req.StoryID > domain.MaxStoryID {
return domain.StoryMessageForwardList{}, domain.ErrStoryIDInvalid
@ -620,9 +712,40 @@ func (s *ChannelStore) visibleTopMessageIDForMemberLocked(channel domain.Channel
return msg.ID
}
}
if member.HistoryClearAnchorID > 0 &&
member.HistoryClearAnchorID == member.AvailableMinID {
return member.HistoryClearAnchorID
}
return 0
}
func (s *ChannelStore) channelMessageForMemberLocked(userID, channelID int64, messageID int) (domain.ChannelMessage, bool) {
member, ok := s.members[channelID][userID]
if !ok {
msg, found := s.findMessageLocked(channelID, messageID)
if !found || msg.Deleted {
return domain.ChannelMessage{}, false
}
return cloneChannelMessage(msg), true
}
if member.HistoryClearAnchorID > 0 &&
member.HistoryClearAnchorID == member.AvailableMinID &&
messageID == member.HistoryClearAnchorID {
source, _ := s.findMessageLocked(channelID, messageID)
return domain.ProjectChannelHistoryClearMessage(
source,
channelID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
), true
}
msg, ok := s.findMessageLocked(channelID, messageID)
if !ok || msg.Deleted || msg.ID <= member.AvailableMinID {
return domain.ChannelMessage{}, false
}
return cloneChannelMessage(msg), true
}
func (s *ChannelStore) topicHasVisibleMessagesLocked(channelID int64, topicID int) bool {
for _, msg := range s.messages[channelID] {
if msg.Deleted {

View file

@ -17,7 +17,7 @@ func (s *ChannelStore) GetChannelMessageViews(_ context.Context, req domain.Chan
}
s.mu.Lock()
defer s.mu.Unlock()
channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID)
channel, member, _, err := s.channelForViewerLocked(req.UserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageViewsResult{}, err
}
@ -33,7 +33,8 @@ func (s *ChannelStore) GetChannelMessageViews(_ context.Context, req domain.Chan
if _, ok := wanted[msg.ID]; !ok {
continue
}
if msg.Deleted || msg.ID <= member.AvailableMinID {
if msg.Deleted || msg.ID <= member.AvailableMinID ||
!channelMessageVisibleToViewerLocked(channel, member, req.UserID, msg) {
continue
}
visible[msg.ID] = struct{}{}

View file

@ -119,6 +119,7 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
Entities: append([]domain.MessageEntity(nil), req.Entities...),
Media: req.Media,
ReplyTo: req.ReplyTo,
Forward: req.Forward,
Pts: pts,
}
// Store owns the persisted snapshot; callers must not be able to mutate it through

View file

@ -27,8 +27,9 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
}
sub := domain.Peer{Type: domain.PeerTypeUser, ID: 42}
forward := &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, Date: 1_700_000_999}
m1, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 111, Message: "hi", Date: 1_700_001_001})
m1, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 111, Message: "hi", Forward: forward, Date: 1_700_001_001})
if err != nil {
t.Fatalf("subscriber send 1: %v", err)
}
@ -75,7 +76,7 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
}
// 幂等:相同 randomID 返回原消息、不重复。
dup, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 111, Message: "hi", Date: 1_700_001_004})
dup, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 111, Message: "hi", Forward: forward, Date: 1_700_001_004})
if err != nil {
t.Fatalf("dup send: %v", err)
}
@ -99,6 +100,9 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
if hist.Messages[0].ReplyTo == nil || hist.Messages[0].ReplyTo.MessageID != m1.Message.ID {
t.Fatalf("history[0] reply = %+v, want message %d", hist.Messages[0].ReplyTo, m1.Message.ID)
}
if oldest := hist.Messages[len(hist.Messages)-1]; oldest.Forward == nil || oldest.Forward.From.ID != 1 || oldest.Forward.Date != 1_700_000_999 {
t.Fatalf("persisted monoforum forward = %+v, want source user 1/date 1700000999", oldest.Forward)
}
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 114, Message: "cross reply", ReplyTo: &domain.MessageReply{MessageID: 999999, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1_700_001_004}); !errors.Is(err, domain.ErrReplyMessageIDInvalid) {
t.Fatalf("invalid monoforum reply err = %v, want ErrReplyMessageIDInvalid", err)
}
@ -110,7 +114,8 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
// 另一个订阅者的私信不串会话。
other := domain.Peer{Type: domain.PeerTypeUser, ID: 99}
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 99, SavedPeer: other, RandomID: 201, Message: "other", Date: 1_700_001_005}); err != nil {
otherMessage, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 99, SavedPeer: other, RandomID: 201, Message: "other", Date: 1_700_001_005})
if err != nil {
t.Fatalf("other subscriber send: %v", err)
}
subHist, _ := store.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: sub, Limit: 10})
@ -129,6 +134,104 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
t.Fatalf("subscriber channel history leaked message %+v", message)
}
}
exactMessages, err := store.GetChannelMessages(ctx, 42, monoID, []int{m1.Message.ID, otherMessage.Message.ID})
if err != nil {
t.Fatalf("subscriber exact monoforum messages: %v", err)
}
if len(exactMessages.Messages) != 1 || exactMessages.Messages[0].ID != m1.Message.ID {
t.Fatalf("subscriber exact monoforum messages = %+v, want only own message %d", exactMessages.Messages, m1.Message.ID)
}
ptsBeforeViews := store.channels[monoID].Pts
subViews, err := store.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{
UserID: 42, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID},
Increment: true, Date: 1_700_001_006,
})
if err != nil {
t.Fatalf("subscriber get monoforum message views: %v", err)
}
if len(subViews.Views) != 1 || subViews.Views[m1.Message.ID] != 1 {
t.Fatalf("subscriber monoforum views = %+v, want own message %d at 1", subViews.Views, m1.Message.ID)
}
if _, ok := subViews.Views[otherMessage.Message.ID]; ok {
t.Fatalf("subscriber monoforum views leaked other saved_peer message %d", otherMessage.Message.ID)
}
repeatedViews, err := store.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{
UserID: 42, ChannelID: monoID, IDs: []int{m1.Message.ID},
Increment: true, Date: 1_700_001_007,
})
if err != nil || repeatedViews.Views[m1.Message.ID] != 1 {
t.Fatalf("repeated subscriber monoforum views = %+v, %v; want idempotent 1", repeatedViews.Views, err)
}
if got := store.msgViews[monoID][otherMessage.Message.ID]; got != 0 {
t.Fatalf("hidden saved_peer views = %d, want 0 before admin view", got)
}
adminViews, err := store.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{
UserID: 1, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID},
Increment: true, Date: 1_700_001_008,
})
if err != nil {
t.Fatalf("admin get monoforum message views: %v", err)
}
if len(adminViews.Views) != 2 || adminViews.Views[m1.Message.ID] != 2 || adminViews.Views[otherMessage.Message.ID] != 1 {
t.Fatalf("admin monoforum views = %+v, want both saved peers at 2/1", adminViews.Views)
}
if got := store.channels[monoID].Pts; got != ptsBeforeViews {
t.Fatalf("message views advanced monoforum pts = %d, want unchanged %d", got, ptsBeforeViews)
}
if _, err := store.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{
UserID: 42, ChannelID: monoID, MessageID: m1.Message.ID,
Reactions: []domain.MessageReaction{{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"}},
Date: 1_700_001_006,
}); err != nil {
t.Fatalf("subscriber react to own monoforum message: %v", err)
}
if _, err := store.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{
UserID: 42, ChannelID: monoID, MessageID: otherMessage.Message.ID,
Reactions: []domain.MessageReaction{{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f525"}},
Date: 1_700_001_006,
}); !errors.Is(err, domain.ErrMessageIDInvalid) {
t.Fatalf("subscriber react to another saved_peer err = %v, want ErrMessageIDInvalid", err)
}
subReactions, err := store.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{
UserID: 42, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID},
})
if err != nil {
t.Fatalf("subscriber get monoforum reactions: %v", err)
}
if len(subReactions.Messages) != 1 || subReactions.Messages[0].ID != m1.Message.ID {
t.Fatalf("subscriber monoforum reactions = %+v, want only own message %d", subReactions.Messages, m1.Message.ID)
}
adminReactions, err := store.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{
UserID: 1, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID},
})
if err != nil {
t.Fatalf("admin get monoforum reactions: %v", err)
}
if len(adminReactions.Messages) != 2 {
t.Fatalf("admin monoforum reactions = %+v, want both subscriber messages", adminReactions.Messages)
}
reactionList, err := store.ListChannelMessageReactions(ctx, domain.ChannelMessageReactionsListRequest{
UserID: 42, ChannelID: monoID, MessageID: m1.Message.ID, Limit: 10,
})
if err != nil || reactionList.Count != 1 || len(reactionList.Reactions) != 1 {
t.Fatalf("subscriber monoforum reaction list = %+v, %v; want one", reactionList, err)
}
if _, err := store.ListChannelMessageReactions(ctx, domain.ChannelMessageReactionsListRequest{
UserID: 42, ChannelID: monoID, MessageID: otherMessage.Message.ID, Limit: 10,
}); !errors.Is(err, domain.ErrMessageIDInvalid) {
t.Fatalf("subscriber list another saved_peer reactions err = %v, want ErrMessageIDInvalid", err)
}
reactionLookup, found, err := store.FindChannelMessageReaction(ctx, domain.ChannelMessageReactionLookupRequest{
ViewerUserID: 42, ChannelID: monoID, MessageID: m1.Message.ID, ReactorUserID: 42,
})
if err != nil || !found || len(reactionLookup.Reactions) != 1 {
t.Fatalf("subscriber monoforum reaction lookup = %+v, %v, %v; want one", reactionLookup, found, err)
}
if _, _, err := store.FindChannelMessageReaction(ctx, domain.ChannelMessageReactionLookupRequest{
ViewerUserID: 42, ChannelID: monoID, MessageID: otherMessage.Message.ID, ReactorUserID: 99,
}); !errors.Is(err, domain.ErrMessageIDInvalid) {
t.Fatalf("subscriber lookup another saved_peer reaction err = %v, want ErrMessageIDInvalid", err)
}
diff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: 42, ChannelID: monoID, Pts: 0, Limit: 100})
if err != nil {
t.Fatalf("subscriber channel difference: %v", err)

View file

@ -41,6 +41,23 @@ func TestSetPaidMessagesPriceCreatesAndReusesMonoforum(t *testing.T) {
if mono.TopMessageID == 0 || mono.Pts == 0 {
t.Fatalf("monoforum top/pts = %d/%d, want service top message", mono.TopMessageID, mono.Pts)
}
for _, userID := range []int64{1, 42} {
read, err := store.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
UserID: userID, ChannelID: monoID, MaxID: mono.TopMessageID, Date: 1_700_000_901,
})
if err != nil {
t.Fatalf("synthetic monoforum read for %d: %v", userID, err)
}
if !read.ReadOnly || read.Changed || read.MaxID != mono.TopMessageID {
t.Fatalf("synthetic monoforum read for %d = %+v, want read-only no-op at %d", userID, read, mono.TopMessageID)
}
if _, exists := store.members[monoID][userID]; exists {
t.Fatalf("synthetic monoforum read persisted member for %d", userID)
}
if _, exists := store.dialogs[userID][monoID]; exists {
t.Fatalf("synthetic monoforum read persisted dialog for %d", userID)
}
}
dialogs, err := store.ListChannelDialogs(ctx, 1, domain.DialogFilter{Limit: 100})
if err != nil {
t.Fatalf("list dialogs after enable: %v", err)

View file

@ -46,7 +46,7 @@ func (s *ChannelStore) SetChannelMessageReactions(_ context.Context, req domain.
}
s.mu.Lock()
defer s.mu.Unlock()
channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID)
channel, member, _, err := s.channelForViewerLocked(req.UserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionsResult{}, err
}
@ -64,7 +64,8 @@ func (s *ChannelStore) SetChannelMessageReactions(_ context.Context, req domain.
return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid
}
msg := s.messages[req.ChannelID][idx]
if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID {
if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID ||
!channelMessageVisibleToViewerLocked(channel, member, req.UserID, msg) {
return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid
}
// 仅新增/替换受策略约束;空向量是撤销,策略收紧后也必须允许撤销存量 reaction。
@ -385,7 +386,7 @@ func (s *ChannelStore) GetChannelMessageReactions(_ context.Context, req domain.
}
s.mu.RLock()
defer s.mu.RUnlock()
channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID)
channel, member, _, err := s.channelForViewerLocked(req.UserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionsResult{}, err
}
@ -401,7 +402,8 @@ func (s *ChannelStore) GetChannelMessageReactions(_ context.Context, req domain.
if _, ok := wanted[msg.ID]; !ok {
continue
}
if msg.Deleted || msg.ID <= member.AvailableMinID {
if msg.Deleted || msg.ID <= member.AvailableMinID ||
!channelMessageVisibleToViewerLocked(channel, member, req.UserID, msg) {
continue
}
item := cloneChannelMessage(msg)
@ -432,7 +434,7 @@ func (s *ChannelStore) ListChannelMessageReactions(_ context.Context, req domain
}
s.mu.RLock()
defer s.mu.RUnlock()
channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID)
channel, member, _, err := s.channelForViewerLocked(req.UserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionsList{}, err
}
@ -440,7 +442,8 @@ func (s *ChannelStore) ListChannelMessageReactions(_ context.Context, req domain
return domain.ChannelMessageReactionsList{}, domain.ErrChannelRightForbidden
}
msg, ok := s.findMessageLocked(req.ChannelID, req.MessageID)
if !ok || msg.Deleted || msg.ID <= member.AvailableMinID {
if !ok || msg.Deleted || msg.ID <= member.AvailableMinID ||
!channelMessageVisibleToViewerLocked(channel, member, req.UserID, msg) {
return domain.ChannelMessageReactionsList{}, domain.ErrMessageIDInvalid
}
rows := s.channelMessageReactionRowsLocked(req.ChannelID, req.MessageID, req.UserID, req.Reaction)
@ -479,6 +482,40 @@ func (s *ChannelStore) ListChannelMessageReactions(_ context.Context, req domain
}, nil
}
func (s *ChannelStore) FindChannelMessageReaction(_ context.Context, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error) {
if req.ViewerUserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
req.MessageID > domain.MaxMessageBoxID || req.ReactorUserID == 0 {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
}
s.mu.RLock()
defer s.mu.RUnlock()
channel, member, _, err := s.channelForViewerLocked(req.ViewerUserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
message, ok := s.findMessageLocked(req.ChannelID, req.MessageID)
if !ok || message.Deleted || message.ID <= member.AvailableMinID ||
!channelMessageVisibleToViewerLocked(channel, member, req.ViewerUserID, message) {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrMessageIDInvalid
}
rows := cloneChannelPeerReactions(s.reactions[req.ChannelID][req.MessageID][req.ReactorUserID])
if len(rows) == 0 {
return domain.ChannelMessageReactionLookup{
Channel: cloneChannel(channel), Message: cloneChannelMessage(message),
}, false, nil
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].ChosenOrder != rows[j].ChosenOrder {
return rows[i].ChosenOrder < rows[j].ChosenOrder
}
return messageReactionKey(rows[i].Reaction) < messageReactionKey(rows[j].Reaction)
})
return domain.ChannelMessageReactionLookup{
Channel: cloneChannel(channel), Message: cloneChannelMessage(message),
Reactions: rows,
}, true, nil
}
func (s *ChannelStore) RecordMessageReactionUse(_ context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error {
if userID == 0 || len(reactions) == 0 {
return nil
@ -599,54 +636,6 @@ func (s *ChannelStore) ClearRecentMessageReactions(_ context.Context, userID int
return nil
}
func (s *ChannelStore) ListSavedReactionTags(_ context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) {
if userID == 0 {
return nil, domain.ErrChannelInvalid
}
if limit <= 0 {
return []domain.SavedReactionTag{}, nil
}
if limit > domain.MaxSavedReactionTags {
limit = domain.MaxSavedReactionTags
}
s.mu.RLock()
defer s.mu.RUnlock()
rows := make([]domain.SavedReactionTag, 0, len(s.savedTags[userID]))
for _, row := range s.savedTags[userID] {
rows = append(rows, row)
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Count != rows[j].Count {
return rows[i].Count > rows[j].Count
}
if rows[i].Reaction.Type != rows[j].Reaction.Type {
return rows[i].Reaction.Type < rows[j].Reaction.Type
}
return rows[i].Reaction.Value() < rows[j].Reaction.Value()
})
if len(rows) > limit {
rows = rows[:limit]
}
return rows, nil
}
func (s *ChannelStore) UpsertSavedReactionTag(_ context.Context, tag domain.SavedReactionTag) error {
if tag.UserID == 0 || tag.Reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(tag.Reaction.Emoticon) == "" {
return domain.ErrChannelInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
if s.savedTags[tag.UserID] == nil {
s.savedTags[tag.UserID] = make(map[string]domain.SavedReactionTag)
}
tag.Reaction.Emoticon = strings.TrimSpace(tag.Reaction.Emoticon)
if tag.Count < 0 {
tag.Count = 0
}
s.savedTags[tag.UserID][messageReactionKey(tag.Reaction)] = tag
return nil
}
func (s *ChannelStore) ListChannelUnreadReactions(_ context.Context, viewerUserID int64, filter domain.ChannelUnreadReactionsFilter) (domain.ChannelHistory, error) {
s.mu.RLock()
defer s.mu.RUnlock()

View file

@ -252,7 +252,7 @@ func (s *ChannelStore) ReadChannelMentions(_ context.Context, req domain.ReadCha
func (s *ChannelStore) ReadChannelHistory(_ context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
channel, _, readOnly, err := s.channelForViewerLocked(req.UserID, req.ChannelID)
if err != nil {
return domain.ReadChannelHistoryResult{}, err
}
@ -260,6 +260,15 @@ func (s *ChannelStore) ReadChannelHistory(_ context.Context, req domain.ReadChan
if maxID <= 0 || maxID > channel.TopMessageID {
maxID = channel.TopMessageID
}
if readOnly {
return domain.ReadChannelHistoryResult{
ChannelID: req.ChannelID,
MaxID: maxID,
ReadOnly: true,
Pts: channel.Pts,
Forum: channel.Forum,
}, nil
}
member := s.members[req.ChannelID][req.UserID]
previous := member.ReadInboxMaxID
changed := maxID > member.ReadInboxMaxID

View file

@ -139,7 +139,7 @@ func (s *ChannelStore) CheckUsername(_ context.Context, userID, channelID int64,
return true, nil
}
func (s *ChannelStore) UpdateUsername(_ context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) {
func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) {
if req.UserID == 0 || req.ChannelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
@ -168,6 +168,11 @@ func (s *ChannelStore) UpdateUsername(_ context.Context, req domain.UpdateChanne
}
}
}
if s.usernameRegistry != nil {
if _, err := s.usernameRegistry.SetEditableUsername(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}, username); err != nil {
return domain.Channel{}, err
}
}
prevUsername := channel.Username
channel.Username = username
s.channels[req.ChannelID] = channel
@ -311,16 +316,27 @@ func (s *ChannelStore) ResolvePublicChannelUsername(_ context.Context, viewerUse
return domain.Channel{}, false, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
registry := s.usernameRegistry
for _, channel := range s.channels {
if !publicSearchableChannel(channel) {
continue
}
if strings.ToLower(channel.Username) == username {
s.mu.RUnlock()
return cloneChannel(channel), true, nil
}
}
s.mu.RUnlock()
if registry != nil {
if peer, ok := registry.activeUsernamePeer(username, domain.PeerTypeChannel); ok {
s.mu.RLock()
channel, found := s.channels[peer.ID]
s.mu.RUnlock()
if found && !channel.Deleted && (channel.Broadcast || channel.Megagroup) {
return cloneChannel(channel), true, nil
}
}
}
return domain.Channel{}, false, nil
}

View file

@ -73,15 +73,19 @@ type ChannelStore struct {
messages map[int64][]domain.ChannelMessage
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
// paidReactions 是 per-(channel,message,user) 付费 reaction 累计星数 + 匿名标志。
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
top map[int64]map[string]domain.TopMessageReaction
recent map[int64]map[string]domain.RecentMessageReaction
savedTags map[int64]map[string]domain.SavedReactionTag
mentions map[int64]map[int64]map[int]memoryMention
msgViews map[int64]map[int]int
msgViewers map[int64]map[int]map[int64]struct{}
events map[int64][]domain.ChannelUpdateEvent
retention map[int64]domain.ChannelUpdateRetentionCheckpoint
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
top map[int64]map[string]domain.TopMessageReaction
recent map[int64]map[string]domain.RecentMessageReaction
mentions map[int64]map[int64]map[int]memoryMention
msgViews map[int64]map[int]int
msgViewers map[int64]map[int]map[int64]struct{}
events map[int64][]domain.ChannelUpdateEvent
retention map[int64]domain.ChannelUpdateRetentionCheckpoint
// historyClearDates is the no-PTS recovery timestamp for a future
// owner-local clear, keyed by channel then user. The member remains the
// absolute boundary authority; this map only makes account difference
// discovery bounded without scanning messages.
historyClearDates map[int64]map[int64]int
adminLogs map[int64][]domain.ChannelAdminLogEvent
invites map[string]domain.ChannelInvite
importers map[int64]map[int64]domain.ChannelInviteImporter
@ -102,7 +106,8 @@ type ChannelStore struct {
// topicReads 是 per-(channel,user,topic) 已读水位(forum 话题独立已读,不碰频道级 member 水位)。
topicReads map[int64]map[int64]map[int]memoryTopicRead
// polls 是共享 poll 权威(与 MessageStore 同一实例);nil 时 poll 链路按未接入处理。
polls *PollStore
polls *PollStore
usernameRegistry *CollectibleUsernameStore
}
// AttachPollStore 注入共享 poll 权威。
@ -110,6 +115,14 @@ func (s *ChannelStore) AttachPollStore(polls *PollStore) {
s.polls = polls
}
// AttachUsernameRegistry gives the memory backend the same global username
// index the PostgreSQL stores share through peer_usernames.
func (s *ChannelStore) AttachUsernameRegistry(registry *CollectibleUsernameStore) {
s.mu.Lock()
s.usernameRegistry = registry
s.mu.Unlock()
}
// NewChannelStore creates an in-memory ChannelStore.
func NewChannelStore() *ChannelStore {
return &ChannelStore{
@ -124,12 +137,12 @@ func NewChannelStore() *ChannelStore {
paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction),
top: make(map[int64]map[string]domain.TopMessageReaction),
recent: make(map[int64]map[string]domain.RecentMessageReaction),
savedTags: make(map[int64]map[string]domain.SavedReactionTag),
mentions: make(map[int64]map[int64]map[int]memoryMention),
msgViews: make(map[int64]map[int]int),
msgViewers: make(map[int64]map[int]map[int64]struct{}),
events: make(map[int64][]domain.ChannelUpdateEvent),
retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint),
historyClearDates: make(map[int64]map[int64]int),
adminLogs: make(map[int64][]domain.ChannelAdminLogEvent),
invites: make(map[string]domain.ChannelInvite),
importers: make(map[int64]map[int64]domain.ChannelInviteImporter),

View file

@ -85,8 +85,12 @@ func (s *ChannelStore) toggleSuggestedPostApprovalLocked(req domain.ToggleSugges
if req.ScheduleDate > 0 {
scheduleDate = req.ScheduleDate
}
if !req.Reject && scheduleDate > 0 && (scheduleDate < req.Date+5*60 || scheduleDate > req.Date+31*24*60*60) {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
if !req.Reject {
effectiveDate, scheduleErr := domain.EffectiveSuggestedPostPublishDate(scheduleDate, req.Date)
if scheduleErr != nil {
return domain.ToggleSuggestedPostApprovalResult{}, scheduleErr
}
scheduleDate = effectiveDate
}
recipients := s.monoforumRecipientsLocked(parent.ID, original.SavedPeer.ID)
base := domain.ToggleSuggestedPostApprovalResult{
@ -136,13 +140,6 @@ func (s *ChannelStore) toggleSuggestedPostApprovalLocked(req domain.ToggleSugges
original.SuggestedPost.Accepted = true
original.SuggestedPost.Rejected = false
effectivePublishDate := scheduleDate
if effectivePublishDate == 0 {
// TDesktop deliberately omits schedule_date for "Publish Now", but
// renders the approval service action as an absolute date. Persist one
// effective publication timestamp across the edited suggestion, action
// and approval record instead of leaking an accepted zero date.
effectivePublishDate = req.Date
}
original.SuggestedPost.ScheduleDate = effectivePublishDate
original.Pts = s.nextChannelPtsLocked(mono.ID)
s.messages[mono.ID][idx] = cloneChannelMessage(original)

View file

@ -179,6 +179,89 @@ func TestSuggestedPostLowBalanceRetryScheduleAndRoleMatrix(t *testing.T) {
}
}
func TestSuggestedPostApprovalAcceptsDelayedScheduleAndKeepsPTSIdempotent(t *testing.T) {
ctx := context.Background()
store, parent, mono, subscriber := newSuggestedPostMemoryFixture(t)
const now = 1_700_010_000
near, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber,
RandomID: 71, Message: "near schedule", SuggestedPost: &domain.SuggestedPost{}, Date: now - 10,
})
if err != nil {
t.Fatal(err)
}
nearDate := now + 2*60
accepted, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
UserID: 1, MonoforumID: mono.ID, MessageID: near.Message.ID,
ScheduleDate: nearDate, Date: now,
})
if err != nil {
t.Fatalf("accept schedule below former five-minute gate: %v", err)
}
if accepted.State != domain.SuggestedPostStateScheduled || accepted.Published != nil ||
accepted.OriginalMessage.SuggestedPost.ScheduleDate != nearDate ||
accepted.ServiceMessage.Action == nil ||
accepted.ServiceMessage.Action.SuggestedPostScheduleDate != nearDate {
t.Fatalf("near schedule approval = %+v, want scheduled at %d", accepted, nearDate)
}
monoPts, parentPts := store.channels[mono.ID].Pts, store.channels[parent.ID].Pts
monoEvents, parentEvents := len(store.events[mono.ID]), len(store.events[parent.ID])
replay, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
UserID: 1, MonoforumID: mono.ID, MessageID: near.Message.ID,
ScheduleDate: nearDate, Date: now + 10*60,
})
if err != nil || !replay.Duplicate {
t.Fatalf("late duplicate approval = %+v err=%v", replay, err)
}
if store.channels[mono.ID].Pts != monoPts || store.channels[parent.ID].Pts != parentPts ||
len(store.events[mono.ID]) != monoEvents || len(store.events[parent.ID]) != parentEvents {
t.Fatal("late duplicate approval advanced PTS or appended an event")
}
due, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber,
RandomID: 72, Message: "already due", SuggestedPost: &domain.SuggestedPost{}, Date: now + 20,
})
if err != nil {
t.Fatal(err)
}
approvedAt := now + 30
dueResult, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
UserID: 1, MonoforumID: mono.ID, MessageID: due.Message.ID,
ScheduleDate: now - 1, Date: approvedAt,
})
if err != nil {
t.Fatalf("approve already-due schedule: %v", err)
}
if dueResult.State != domain.SuggestedPostStateCompleted || dueResult.Published == nil ||
dueResult.OriginalMessage.SuggestedPost.ScheduleDate != approvedAt ||
dueResult.ServiceMessage.Action == nil ||
dueResult.ServiceMessage.Action.SuggestedPostScheduleDate != approvedAt {
t.Fatalf("due schedule approval = %+v, want immediate publish at %d", dueResult, approvedAt)
}
far, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber,
RandomID: 73, Message: "too far", SuggestedPost: &domain.SuggestedPost{}, Date: now + 40,
})
if err != nil {
t.Fatal(err)
}
monoPts, parentPts = store.channels[mono.ID].Pts, store.channels[parent.ID].Pts
monoEvents, parentEvents = len(store.events[mono.ID]), len(store.events[parent.ID])
if _, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
UserID: 1, MonoforumID: mono.ID, MessageID: far.Message.ID,
ScheduleDate: approvedAt + domain.MaxSuggestedPostScheduleDelay + 1, Date: approvedAt,
}); !errors.Is(err, domain.ErrSuggestedPostInvalid) {
t.Fatalf("far schedule err=%v, want suggested post invalid", err)
}
if store.channels[mono.ID].Pts != monoPts || store.channels[parent.ID].Pts != parentPts ||
len(store.events[mono.ID]) != monoEvents || len(store.events[parent.ID]) != parentEvents {
t.Fatal("far schedule rejection advanced PTS or appended an event")
}
}
func TestChannelAuthoredSuggestedPostAcceptedBySubscriber(t *testing.T) {
ctx := context.Background()
store, _, mono, subscriber := newSuggestedPostMemoryFixture(t)

View file

@ -873,6 +873,7 @@ func TestChannelDeleteHistoryLocalClearReturnsMonotonicAvailableMinID(t *testing
CreatorUserID: 1,
Title: "monotonic local clear",
Megagroup: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_250,
})
if err != nil {
@ -911,6 +912,20 @@ func TestChannelDeleteHistoryLocalClearReturnsMonotonicAvailableMinID(t *testing
if high.AvailableMinID != second.Message.ID {
t.Fatalf("high available_min_id = %d, want %d", high.AvailableMinID, second.Message.ID)
}
if !high.AvailableMinChanged {
t.Fatal("high clear did not report an advanced owner-local boundary")
}
dirtyAfterClear, err := store.ListDirtyActiveChannelsForUser(ctx, 1, 1_700_000_253, 0, 10)
if err != nil {
t.Fatalf("list dirty channels after owner-local clear: %v", err)
}
if len(dirtyAfterClear) != 1 ||
dirtyAfterClear[0].ChannelID != created.Channel.ID ||
dirtyAfterClear[0].AvailableMinID != second.Message.ID ||
dirtyAfterClear[0].HistoryClearDate != 1_700_000_253 ||
dirtyAfterClear[0].ChannelUpdatesDirty {
t.Fatalf("dirty owner-local clear = %+v, want only absolute boundary %d at date 1700000253", dirtyAfterClear, second.Message.ID)
}
stale, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: 1,
@ -924,13 +939,38 @@ func TestChannelDeleteHistoryLocalClearReturnsMonotonicAvailableMinID(t *testing
if stale.AvailableMinID != second.Message.ID {
t.Fatalf("stale available_min_id = %d, want monotonic %d", stale.AvailableMinID, second.Message.ID)
}
if stale.AvailableMinChanged {
t.Fatal("stale clear unexpectedly replaced the owner-local anchor")
}
dirtyAfterStale, err := store.ListDirtyActiveChannelsForUser(ctx, 1, 1_700_000_254, 0, 10)
if err != nil {
t.Fatalf("list dirty channels after stale owner-local clear: %v", err)
}
if len(dirtyAfterStale) != 0 {
t.Fatalf("stale clear refreshed recovery timestamp: %+v", dirtyAfterStale)
}
history, err := store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10})
if err != nil {
t.Fatalf("list history: %v", err)
}
if len(history.Messages) != 0 {
t.Fatalf("history after stale clear = %+v, want no visible messages", history.Messages)
t.Fatalf("unprojected history after stale clear = %+v, want no shared messages", history.Messages)
}
history, err = store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{
ChannelID: created.Channel.ID,
Limit: 10,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("list projected history: %v", err)
}
if len(history.Messages) != 1 ||
history.Messages[0].ID != second.Message.ID ||
!domain.IsChannelHistoryClearMessage(history.Messages[0]) ||
history.Messages[0].Body != "" ||
history.Messages[0].Media != nil {
t.Fatalf("projected history after stale clear = %+v, want sanitized history-clear anchor %d", history.Messages, second.Message.ID)
}
dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID})
if err != nil {
@ -939,8 +979,140 @@ func TestChannelDeleteHistoryLocalClearReturnsMonotonicAvailableMinID(t *testing
if len(dialogs.Dialogs) != 1 {
t.Fatalf("dialogs = %+v, want one dialog", dialogs.Dialogs)
}
if dialogs.Dialogs[0].TopMessage != 0 || dialogs.Dialogs[0].ReadInboxMaxID != second.Message.ID || dialogs.Dialogs[0].UnreadCount != 0 {
t.Fatalf("dialog after stale clear = %+v, want top=0 read=%d unread=0", dialogs.Dialogs[0], second.Message.ID)
if dialogs.Dialogs[0].TopMessage != second.Message.ID ||
dialogs.Dialogs[0].ReadInboxMaxID != second.Message.ID ||
dialogs.Dialogs[0].UnreadCount != 0 ||
len(dialogs.Messages) != 1 ||
!domain.IsChannelHistoryClearMessage(dialogs.Messages[0]) {
t.Fatalf("dialog after stale clear = %+v messages=%+v, want anchored top=%d read=%d unread=0", dialogs.Dialogs[0], dialogs.Messages, second.Message.ID, second.Message.ID)
}
otherDialogs, err := store.GetChannelDialogs(ctx, 2, []int64{created.Channel.ID})
if err != nil {
t.Fatalf("get other member dialog: %v", err)
}
if len(otherDialogs.Dialogs) != 1 ||
otherDialogs.Dialogs[0].TopMessage != second.Message.ID ||
len(otherDialogs.Messages) != 1 ||
otherDialogs.Messages[0].Body != "second visible" ||
otherDialogs.Messages[0].Action != nil {
t.Fatalf("other member projection changed by owner clear: dialogs=%+v messages=%+v", otherDialogs.Dialogs, otherDialogs.Messages)
}
}
func TestChannelDeleteHistoryLocalClearKeepsMegagroupAndBroadcastDialogs(t *testing.T) {
for _, tc := range []struct {
name string
megagroup bool
broadcast bool
}{
{name: "megagroup", megagroup: true},
{name: "broadcast", broadcast: true},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 11,
Title: tc.name + " local clear",
Megagroup: tc.megagroup,
Broadcast: tc.broadcast,
Date: 1_700_000_270,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 11,
ChannelID: created.Channel.ID,
RandomID: 31_001,
Message: "clear this",
Date: 1_700_000_271,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
channelPts := sent.Channel.Pts
cleared, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: 11,
ChannelID: created.Channel.ID,
MaxID: sent.Message.ID,
Date: 1_700_000_272,
})
if err != nil {
t.Fatalf("clear local history: %v", err)
}
if cleared.Channel.Pts != channelPts || cleared.Event.Pts != 0 {
t.Fatalf("local clear changed channel pts: before=%d result=%+v", channelPts, cleared)
}
dialogs, err := store.GetChannelDialogs(ctx, 11, []int64{created.Channel.ID})
if err != nil {
t.Fatalf("get dialogs after clear: %v", err)
}
if len(dialogs.Dialogs) != 1 ||
dialogs.Dialogs[0].TopMessage != sent.Message.ID ||
len(dialogs.Messages) != 1 ||
!domain.IsChannelHistoryClearMessage(dialogs.Messages[0]) {
t.Fatalf("dialog disappeared after %s clear: dialogs=%+v messages=%+v", tc.name, dialogs.Dialogs, dialogs.Messages)
}
})
}
}
func TestChannelPrehistoryBoundaryDoesNotCreateHistoryClearAnchor(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 21,
Title: "hidden prehistory",
Megagroup: true,
Date: 1_700_000_280,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
old, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 21,
ChannelID: created.Channel.ID,
RandomID: 32_001,
Message: "prehistory",
Date: 1_700_000_281,
})
if err != nil {
t.Fatalf("send prehistory message: %v", err)
}
if _, err := store.SetPreHistoryHidden(ctx, 21, created.Channel.ID, true); err != nil {
t.Fatalf("hide prehistory: %v", err)
}
invited, err := store.InviteToChannel(ctx, created.Channel.ID, 21, []int64{22}, 1_700_000_282)
if err != nil {
t.Fatalf("invite member: %v", err)
}
if len(invited.Members) != 1 ||
invited.Members[0].AvailableMinID != old.Message.ID ||
invited.Members[0].HistoryClearAnchorID != 0 ||
invited.Members[0].HistoryClearAnchorDate != 0 {
t.Fatalf("invited member = %+v, want prehistory boundary without local-clear anchor", invited.Members)
}
history, err := store.ListChannelHistory(ctx, 22, domain.ChannelHistoryFilter{
ChannelID: created.Channel.ID,
Limit: 10,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("list invited member history: %v", err)
}
for _, message := range history.Messages {
if message.ID == old.Message.ID || domain.IsChannelHistoryClearMessage(message) {
t.Fatalf("prehistory boundary leaked a local-clear marker: %+v", history.Messages)
}
}
byID, err := store.GetChannelMessages(ctx, 22, created.Channel.ID, []int{old.Message.ID})
if err != nil {
t.Fatalf("get prehistory message by id: %v", err)
}
if len(byID.Messages) != 0 {
t.Fatalf("prehistory message by id = %+v, want hidden without fabricated marker", byID.Messages)
}
}

View file

@ -31,16 +31,6 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
if preview {
dialog = previewChannelDialog(req.UserID, channel, member)
}
if preview && member.Status != domain.ChannelMemberActive {
return domain.ChannelDifference{
Channel: channel,
Self: member,
Pts: channel.Pts,
Final: true,
Timeout: 30,
Dialog: dialog,
}, nil
}
checkpoint := s.channelUpdateCheckpointLocked(req.ChannelID, channel)
if req.Pts < checkpoint.RetainedThroughPts || channel.Pts-req.Pts > limit {
messages := make([]domain.ChannelMessage, 0, domain.MaxChannelDifferenceTooLongMessages)
@ -81,10 +71,15 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
}
}
}
scanned := 0
for _, event := range s.events[req.ChannelID] {
if event.Pts <= req.Pts {
continue
}
if scanned >= limit {
break
}
scanned++
lastPts = event.Pts
visible, ok := domain.FilterChannelUpdateEventForAvailableMinID(cloneChannelEvent(event), member.AvailableMinID)
if !ok {
@ -106,7 +101,7 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
Channel: channel,
Self: member,
Pts: maxInt(lastPts, req.Pts),
Final: true,
Final: lastPts >= channel.Pts,
Timeout: 30,
Dialog: dialog,
}, nil

View file

@ -0,0 +1,99 @@
package memory
import (
"context"
"sort"
"sync"
"time"
"telesrv/internal/domain"
)
type ClientTelemetryStore struct {
mu sync.Mutex
nextID int64
byID map[int64]domain.ClientTelemetryEvent
byFingerprint map[[32]byte]int64
}
func NewClientTelemetryStore() *ClientTelemetryStore {
return &ClientTelemetryStore{
nextID: 1, byID: make(map[int64]domain.ClientTelemetryEvent),
byFingerprint: make(map[[32]byte]int64),
}
}
func (s *ClientTelemetryStore) CreateClientTelemetry(_ context.Context, event domain.ClientTelemetryEvent) (domain.ClientTelemetryEvent, bool, error) {
if err := event.Validate(); err != nil || event.ID != 0 {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
if id, ok := s.byFingerprint[event.Fingerprint]; ok {
return cloneClientTelemetry(s.byID[id]), false, nil
}
var hourly, daily int
for _, existing := range s.byID {
if existing.UserID != event.UserID ||
existing.CreatedAt.After(event.CreatedAt) {
continue
}
if !existing.CreatedAt.Before(event.CreatedAt.Add(-24 * time.Hour)) {
daily++
}
if !existing.CreatedAt.Before(event.CreatedAt.Add(-time.Hour)) {
hourly++
}
}
if hourly >= domain.MaxClientTelemetryEventsPerHour ||
daily >= domain.MaxClientTelemetryEventsPerDay {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryRateLimited
}
event.ID = s.nextID
s.nextID++
event = cloneClientTelemetry(event)
s.byID[event.ID] = event
s.byFingerprint[event.Fingerprint] = event.ID
return cloneClientTelemetry(event), true, nil
}
func (s *ClientTelemetryStore) DeleteExpiredClientTelemetry(_ context.Context, olderThan time.Time, limit int) (int, error) {
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
return 0, domain.ErrClientTelemetryInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
ids := make([]int64, 0)
for id, event := range s.byID {
if event.CreatedAt.Before(olderThan) {
ids = append(ids, id)
}
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
if len(ids) > limit {
ids = ids[:limit]
}
for _, id := range ids {
event := s.byID[id]
delete(s.byFingerprint, event.Fingerprint)
delete(s.byID, id)
}
return len(ids), nil
}
func (s *ClientTelemetryStore) Events() []domain.ClientTelemetryEvent {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.ClientTelemetryEvent, 0, len(s.byID))
for _, event := range s.byID {
out = append(out, cloneClientTelemetry(event))
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func cloneClientTelemetry(event domain.ClientTelemetryEvent) domain.ClientTelemetryEvent {
event.SubjectIDs = append([]int64(nil), event.SubjectIDs...)
event.Payload = append([]byte(nil), event.Payload...)
return event
}

View file

@ -0,0 +1,62 @@
package memory
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
)
func TestClientTelemetryStoreIdempotencyRateLimitAndRetention(t *testing.T) {
ctx := context.Background()
store := NewClientTelemetryStore()
now := time.Unix(1_750_000_000, 0).UTC()
newEvent := func(subject int64, at time.Time) domain.ClientTelemetryEvent {
event, err := domain.NewClientTelemetryEvent(
71, domain.ClientTelemetryMessageDelivery,
domain.Peer{Type: domain.PeerTypeUser, ID: 72},
[]int64{subject}, map[string]any{"push": true}, at,
)
if err != nil {
t.Fatal(err)
}
return event
}
first := newEvent(1, now)
stored, created, err := store.CreateClientTelemetry(ctx, first)
if err != nil || !created || stored.ID <= 0 {
t.Fatalf("first=%+v created=%v err=%v", stored, created, err)
}
retry, created, err := store.CreateClientTelemetry(ctx, first)
if err != nil || created || retry.ID != stored.ID {
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
}
for i := 1; i < domain.MaxClientTelemetryEventsPerHour; i++ {
if _, created, err := store.CreateClientTelemetry(
ctx, newEvent(int64(i+1), now),
); err != nil || !created {
t.Fatalf("create %d created=%v err=%v", i, created, err)
}
}
if got, created, err := store.CreateClientTelemetry(ctx, first); err != nil ||
created || got.ID != stored.ID {
t.Fatalf("retry at limit got=%+v created=%v err=%v", got, created, err)
}
if _, _, err := store.CreateClientTelemetry(
ctx, newEvent(domain.MaxClientTelemetryEventsPerHour+1, now),
); !errors.Is(err, domain.ErrClientTelemetryRateLimited) {
t.Fatalf("overflow err=%v", err)
}
deleted, err := store.DeleteExpiredClientTelemetry(
ctx, now.Add(time.Second), domain.MaxClientTelemetryEventsPerHour+1,
)
if err != nil || deleted != domain.MaxClientTelemetryEventsPerHour {
t.Fatalf("deleted=%d err=%v", deleted, err)
}
recreated, created, err := store.CreateClientTelemetry(ctx, first)
if err != nil || !created || recreated.ID == stored.ID {
t.Fatalf("recreated=%+v created=%v err=%v", recreated, created, err)
}
}

View file

@ -0,0 +1,742 @@
package memory
import (
"context"
"sort"
"strings"
"sync"
"time"
"telesrv/internal/domain"
)
// Default page sizes applied when an admin filter leaves the limit unset. The
// PostgreSQL queries page with LIMIT, so an unset limit has to resolve to a
// finite page in both backends.
const (
defaultCollectibleUsernameListLimit = 50
defaultCollectibleUsernameTransferLimit = 50
)
// CollectibleUsernameStore is the in-memory implementation of both
// store.UsernameRegistryStore and store.CollectibleUsernameStore. RPC unit tests
// run against it, so it reproduces every invariant migration 0150 encodes as an
// index or CHECK constraint and returns the same domain errors PostgreSQL maps
// its violations onto:
//
// - peer_usernames_peer_editable_idx: exactly one editable row per peer.
// - peer_usernames_collectible_not_editable_check: a row backed by an asset is
// never editable, so client-driven username edits cannot move an asset.
// - peer_usernames.username_lower UNIQUE: global, case-insensitive name
// uniqueness across users and channels, keyed by lower(username).
// - collectible_usernames.username_lower UNIQUE: a name is minted at most
// once. A burn therefore releases the registry row -- the name can be
// occupied again as a peer username -- while the asset row keeps the name
// for provenance and blocks a second mint.
// - the status/owner CHECK pair: owner is populated exactly for status 'owned'.
// - collectible_username_transfers_command_idx: command keys are globally
// unique, which is what makes mint/transfer/revoke replay-safe.
type CollectibleUsernameStore struct {
mu sync.Mutex
nextAssetID int64
nextTransferID int64
// assets is collectible_usernames keyed by identity.
assets map[int64]domain.CollectibleUsername
// assetsByName resolves a name onto the asset it currently stands for. After
// migration 0152 uniqueness covers live rows only, so one name can accumulate
// several burned rows plus at most one live row; this index points at the live
// row when there is one and at the newest burned row otherwise, mirroring the
// SQL lookup order.
assetsByName map[string]int64
// registry is peer_usernames keyed by username_lower, which is exactly how
// the table enforces global uniqueness.
registry map[string]collectibleRegistryRow
// transfers is the append-only provenance log per asset.
transfers map[int64][]domain.CollectibleUsernameTransfer
// commands maps a provenance command key onto the asset it touched.
commands map[string]int64
}
// collectibleRegistryRow is one peer_usernames row: the owning peer plus the
// projected username shape.
type collectibleRegistryRow struct {
peer domain.Peer
row domain.Username
}
// NewCollectibleUsernameStore creates an empty registry. Asset ids start at 1 so
// a zero CollectibleID keeps meaning "editable slot", matching the nullable
// collectible_id column.
func NewCollectibleUsernameStore() *CollectibleUsernameStore {
return &CollectibleUsernameStore{
nextAssetID: 1,
nextTransferID: 1,
assets: make(map[int64]domain.CollectibleUsername),
assetsByName: make(map[string]int64),
registry: make(map[string]collectibleRegistryRow),
transfers: make(map[int64][]domain.CollectibleUsernameTransfer),
commands: make(map[string]int64),
}
}
// SetEditableUsername writes the peer's editable slot, mirroring the
// replace-then-insert the PostgreSQL user and channel stores run inside
// account.updateUsername / channels.updateUsername. The memory backend keeps
// usernames on the user and channel rows, so tests need this hook to give a peer
// the editable registry row the projection expects. An empty username clears the
// slot.
func (s *CollectibleUsernameStore) SetEditableUsername(_ context.Context, peer domain.Peer, username string) (bool, error) {
if !validCollectibleUsernamePeer(peer) {
return false, domain.ErrUsernameInvalid
}
username = domain.NormalizeUsername(username)
s.mu.Lock()
defer s.mu.Unlock()
if username == "" {
return s.clearEditableLocked(peer), nil
}
// peer_usernames has no length CHECK; the 5..32 editable rule lives in the
// service layer. Only the character rules are a registry concern.
if !domain.ValidCollectibleUsername(username) {
return false, domain.ErrUsernameInvalid
}
key := strings.ToLower(username)
if existing, ok := s.registry[key]; ok {
if existing.peer == peer && existing.row.Editable {
if existing.row.Username == username {
return false, nil
}
existing.row.Username = username
s.registry[key] = existing
return true, nil
}
return false, domain.ErrUsernameOccupied
}
// A live asset owns its name even while it sits in the vault: only a burn
// puts the name back into the free pool.
if id, ok := s.assetsByName[key]; ok && s.assets[id].Status != domain.CollectibleUsernameStatusBurned {
return false, domain.ErrUsernameOccupied
}
s.clearEditableLocked(peer)
s.registry[key] = collectibleRegistryRow{
peer: peer,
row: domain.Username{
Username: username,
Active: true,
Editable: true,
},
}
return true, nil
}
// PeerUsernames returns the peer's registry rows in projection order.
func (s *CollectibleUsernameStore) PeerUsernames(_ context.Context, peer domain.Peer) ([]domain.Username, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.peerUsernamesLocked(peer), nil
}
// PeerUsernamesBatch resolves several peers at once; peers holding no username
// are absent from the result.
func (s *CollectibleUsernameStore) PeerUsernamesBatch(_ context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
out := make(map[domain.Peer][]domain.Username, len(peers))
if len(peers) == 0 {
return out, nil
}
s.mu.Lock()
defer s.mu.Unlock()
for _, peer := range peers {
if !validCollectibleUsernamePeer(peer) {
continue
}
if _, done := out[peer]; done {
continue
}
rows := s.peerUsernamesLocked(peer)
if len(rows) == 0 {
continue
}
out[peer] = rows
}
return out, nil
}
// activeUsernamePeer resolves an active registry name for the memory user and
// channel stores. Keeping lookup on the same registry that owns toggle/reorder
// state prevents the test backend from silently falling back to scalar-only
// behavior.
func (s *CollectibleUsernameStore) activeUsernamePeer(username string, peerType domain.PeerType) (domain.Peer, bool) {
key := strings.ToLower(domain.NormalizeUsername(username))
if key == "" {
return domain.Peer{}, false
}
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.registry[key]
if !ok || !entry.row.Active || entry.peer.Type != peerType {
return domain.Peer{}, false
}
return entry.peer, true
}
// activeUsernameMatches returns the best username rank for each peer: exact
// matches precede prefix matches. Inactive rows stay occupied in the registry
// but are deliberately absent from client search.
func (s *CollectibleUsernameStore) activeUsernameMatches(query string, peerType domain.PeerType) map[int64]int {
query = strings.ToLower(domain.NormalizeUsername(query))
if query == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
out := make(map[int64]int)
for username, entry := range s.registry {
if !entry.row.Active || entry.peer.Type != peerType || !strings.HasPrefix(username, query) {
continue
}
rank := 1
if username == query {
rank = 0
}
if current, ok := out[entry.peer.ID]; !ok || rank < current {
out[entry.peer.ID] = rank
}
}
return out
}
func (s *CollectibleUsernameStore) peerHasActiveCollectibleUsername(peer domain.Peer) bool {
s.mu.Lock()
defer s.mu.Unlock()
for _, entry := range s.registry {
if entry.peer == peer && entry.row.Active && !entry.row.Editable {
return true
}
}
return false
}
// SetUsernameActive toggles one collectible row. The domain validator owns the
// rules: the editable slot is off limits and a peer that holds usernames must
// keep at least one active.
func (s *CollectibleUsernameStore) SetUsernameActive(_ context.Context, peer domain.Peer, username string, active bool) (bool, error) {
if !validCollectibleUsernamePeer(peer) {
return false, domain.ErrUsernameInvalid
}
username = domain.NormalizeUsername(username)
s.mu.Lock()
defer s.mu.Unlock()
current := s.peerUsernamesLocked(peer)
if err := domain.ValidateUsernameToggle(current, username, active); err != nil {
return false, err
}
key := strings.ToLower(username)
entry := s.registry[key]
if entry.row.Active == active {
return false, nil
}
entry.row.Active = active
s.registry[key] = entry
return true, nil
}
// ReorderUsernames rewrites the peer's username sort order, editable slot
// included. Validation and the resulting order both come from the domain helper,
// so the two backends cannot drift.
func (s *CollectibleUsernameStore) ReorderUsernames(_ context.Context, peer domain.Peer, order []string) (bool, error) {
if !validCollectibleUsernamePeer(peer) {
return false, domain.ErrUsernameInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
current := s.peerUsernamesLocked(peer)
reordered, err := domain.ApplyUsernameReorder(current, order)
if err != nil {
return false, err
}
// Renumbering always happens; "changed" is about what a client can see.
changed := !domain.SameUsernameOrder(current, reordered)
for _, row := range reordered {
key := strings.ToLower(row.Username)
if key == "" {
continue
}
entry := s.registry[key]
if entry.row.SortOrder == row.SortOrder {
continue
}
entry.row.SortOrder = row.SortOrder
s.registry[key] = entry
}
return changed, nil
}
// DeactivateAllUsernames clears the active flag on every collectible row and
// leaves the editable slot alone.
func (s *CollectibleUsernameStore) DeactivateAllUsernames(_ context.Context, peer domain.Peer) (bool, error) {
if !validCollectibleUsernamePeer(peer) {
return false, domain.ErrUsernameInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
changed := false
for key, entry := range s.registry {
if entry.peer != peer || !entry.row.Collectible() || !entry.row.Active {
continue
}
entry.row.Active = false
s.registry[key] = entry
changed = true
}
return changed, nil
}
// MintCollectibleUsername creates the asset, optionally assigning it in the same
// call. A replayed command key returns the recorded asset with created=false.
func (s *CollectibleUsernameStore) MintCollectibleUsername(_ context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
req.Username = domain.NormalizeUsername(req.Username)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if asset, ok := s.replayLocked(req.CommandKey); ok {
return asset, false, nil
}
key := strings.ToLower(req.Username)
// Only a live asset occupies a name. A name whose history is entirely burned
// is free to be issued again, and the new asset takes over the index entry
// while the burned rows stay as provenance.
if id, ok := s.assetsByName[key]; ok && s.assets[id].Status != domain.CollectibleUsernameStatusBurned {
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
}
if _, ok := s.registry[key]; ok {
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
}
now := time.Now().UTC()
purchaseDate := req.PurchaseDate
if purchaseDate.IsZero() {
purchaseDate = now
}
asset := domain.CollectibleUsername{
ID: s.nextAssetID,
Username: req.Username,
Status: domain.CollectibleUsernameStatusVault,
PurchaseDate: purchaseDate,
Currency: req.Currency,
Amount: req.Amount,
CryptoCurrency: req.CryptoCurrency,
CryptoAmount: req.CryptoAmount,
URL: req.URL,
Version: 1,
CreatedAt: now,
UpdatedAt: now,
}
if req.Owner.Type != "" {
asset.Status = domain.CollectibleUsernameStatusOwned
asset.Owner = req.Owner
// The first holder is the original owner and survives every later move.
asset.OriginalOwner = req.Owner
}
if err := asset.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
if asset.Owned() && s.countCollectiblesLocked(req.Owner) >= domain.MaxPeerCollectibleUsernames {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameLimit
}
s.nextAssetID++
s.assets[asset.ID] = asset
s.assetsByName[key] = asset.ID
if asset.Owned() {
s.attachLocked(asset, req.Owner)
}
s.recordTransferLocked(domain.CollectibleUsernameTransfer{
CollectibleID: asset.ID,
Kind: domain.CollectibleUsernameKindMint,
To: req.Owner,
Currency: req.Currency,
Amount: req.Amount,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: req.CommandKey,
CreatedAt: now,
})
return asset, true, nil
}
// TransferCollectibleUsername moves the asset out of the vault or between
// holders. Handing the asset to the peer that already holds it is a no-op.
func (s *CollectibleUsernameStore) TransferCollectibleUsername(_ context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
req.Username = domain.NormalizeUsername(req.Username)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if asset, ok := s.replayLocked(req.CommandKey); ok {
return asset, false, nil
}
asset, err := s.assetByNameLocked(req.Username)
if err != nil {
return domain.CollectibleUsername{}, false, err
}
if asset.Status == domain.CollectibleUsernameStatusBurned {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
}
if asset.Owned() && asset.Owner == req.To {
return asset, false, nil
}
key := strings.ToLower(asset.Username)
// Defensive: the registry row for this name must be the asset's own. A live
// asset occupies its name globally, so the only way another row can hold it
// is an inconsistently seeded store.
if existing, ok := s.registry[key]; ok && existing.row.CollectibleID != asset.ID {
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
}
if s.countCollectiblesLocked(req.To) >= domain.MaxPeerCollectibleUsernames {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameLimit
}
from := asset.Owner
now := time.Now().UTC()
asset.Status = domain.CollectibleUsernameStatusOwned
asset.Owner = req.To
if asset.OriginalOwner.Type == "" {
asset.OriginalOwner = req.To
}
asset.TransferCount++
asset.Version++
asset.UpdatedAt = now
if err := asset.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.assets[asset.ID] = asset
s.detachLocked(asset.ID)
s.attachLocked(asset, req.To)
s.recordTransferLocked(domain.CollectibleUsernameTransfer{
CollectibleID: asset.ID,
Kind: domain.CollectibleUsernameKindTransfer,
From: from,
To: req.To,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: req.CommandKey,
CreatedAt: now,
})
return asset, true, nil
}
// RevokeCollectibleUsername returns the asset to the vault, or burns it.
//
// A revoke keeps the name owned by the asset -- nobody else can take it -- while
// a burn drops the registry row and releases the name back to the free pool. The
// burned asset row itself survives with its name so provenance stays readable
// and the name never mints twice.
func (s *CollectibleUsernameStore) RevokeCollectibleUsername(_ context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
req.Username = domain.NormalizeUsername(req.Username)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if asset, ok := s.replayLocked(req.CommandKey); ok {
return asset, false, nil
}
asset, err := s.assetByNameLocked(req.Username)
if err != nil {
return domain.CollectibleUsername{}, false, err
}
if asset.Status == domain.CollectibleUsernameStatusBurned {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
}
if !req.Burn && !asset.Owned() {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameNotOwned
}
from := asset.Owner
now := time.Now().UTC()
kind := domain.CollectibleUsernameKindRevoke
asset.Status = domain.CollectibleUsernameStatusVault
if req.Burn {
kind = domain.CollectibleUsernameKindBurn
asset.Status = domain.CollectibleUsernameStatusBurned
}
asset.Owner = domain.Peer{}
asset.Version++
asset.UpdatedAt = now
if err := asset.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.assets[asset.ID] = asset
s.detachLocked(asset.ID)
s.recordTransferLocked(domain.CollectibleUsernameTransfer{
CollectibleID: asset.ID,
Kind: kind,
From: from,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: req.CommandKey,
CreatedAt: now,
})
return asset, true, nil
}
// CollectibleUsername looks the asset up by name, case-insensitively.
// DeleteCollectibleUsername removes the live asset for a name completely --
// registry row, asset and provenance -- and frees the name for any use. Revoke
// with Burn retires an asset but keeps its history; this is the escape hatch for
// an asset issued by mistake.
//
// A command key cannot make this idempotent: the record it would resolve to is
// gone. A repeated call therefore reports deleted=false once no live asset is
// left, which is also what a delete of a burned-only name reports.
func (s *CollectibleUsernameStore) DeleteCollectibleUsername(_ context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
if s == nil {
return false, nil
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return false, err
}
s.mu.Lock()
defer s.mu.Unlock()
key := strings.ToLower(req.Username)
id, ok := s.assetsByName[key]
if !ok {
return false, nil
}
asset, ok := s.assets[id]
if !ok || asset.Status == domain.CollectibleUsernameStatusBurned {
return false, nil
}
s.detachLocked(id)
delete(s.assets, id)
delete(s.transfers, id)
for commandKey, target := range s.commands {
if target == id {
delete(s.commands, commandKey)
}
}
s.rebindAssetNameLocked(key)
return true, nil
}
// rebindAssetNameLocked re-points the name index after a row disappears: the
// newest remaining row wins, and the entry is dropped when none is left.
func (s *CollectibleUsernameStore) rebindAssetNameLocked(key string) {
best := int64(0)
for id, asset := range s.assets {
if strings.ToLower(asset.Username) != key {
continue
}
if best == 0 || id > best {
best = id
}
}
if best == 0 {
delete(s.assetsByName, key)
return
}
s.assetsByName[key] = best
}
func (s *CollectibleUsernameStore) CollectibleUsername(_ context.Context, username string) (domain.CollectibleUsername, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.assetByNameLocked(domain.NormalizeUsername(username))
}
// CollectibleUsernameByID looks the asset up by identity.
func (s *CollectibleUsernameStore) CollectibleUsernameByID(_ context.Context, id int64) (domain.CollectibleUsername, error) {
s.mu.Lock()
defer s.mu.Unlock()
asset, ok := s.assets[id]
if !ok {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
return asset, nil
}
// ListCollectibleUsernames is the admin listing query: newest first, paged by a
// BeforeID keyset, matching collectible_usernames_status_idx ordering.
func (s *CollectibleUsernameStore) ListCollectibleUsernames(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
if filter.Status != "" && !filter.Status.Valid() {
return nil, domain.ErrCollectibleUsernameStateInvalid
}
limit := filter.Limit
if limit <= 0 {
limit = defaultCollectibleUsernameListLimit
}
query := strings.ToLower(domain.NormalizeUsername(filter.Query))
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.CollectibleUsername, 0, len(s.assets))
for _, asset := range s.assets {
if filter.Status != "" && asset.Status != filter.Status {
continue
}
if filter.Owner.Type != "" && asset.Owner != filter.Owner {
continue
}
if query != "" && !strings.Contains(strings.ToLower(asset.Username), query) {
continue
}
if filter.BeforeID > 0 && asset.ID >= filter.BeforeID {
continue
}
out = append(out, asset)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
// CollectibleUsernameTransfers returns the provenance log newest first.
func (s *CollectibleUsernameStore) CollectibleUsernameTransfers(_ context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
if limit <= 0 {
limit = defaultCollectibleUsernameTransferLimit
}
s.mu.Lock()
defer s.mu.Unlock()
stored := s.transfers[collectibleID]
out := make([]domain.CollectibleUsernameTransfer, 0, len(stored))
for i := len(stored) - 1; i >= 0 && len(out) < limit; i-- {
out = append(out, stored[i])
}
return out, nil
}
// peerUsernamesLocked collects the peer's rows in projection order. The rows are
// values, so the returned slice cannot be used to mutate stored state.
func (s *CollectibleUsernameStore) peerUsernamesLocked(peer domain.Peer) []domain.Username {
rows := make([]domain.Username, 0, 4)
for _, entry := range s.registry {
if entry.peer != peer {
continue
}
rows = append(rows, entry.row)
}
return domain.SortUsernames(rows)
}
// countCollectiblesLocked counts the peer's collectible registry rows, which is
// what MaxPeerCollectibleUsernames bounds.
func (s *CollectibleUsernameStore) countCollectiblesLocked(peer domain.Peer) int {
count := 0
for _, entry := range s.registry {
if entry.peer == peer && entry.row.Collectible() {
count++
}
}
return count
}
// nextSortOrderLocked appends the new collectible after the peer's existing ones,
// clamped by the registry sort_order CHECK.
func (s *CollectibleUsernameStore) nextSortOrderLocked(peer domain.Peer) int {
next := 0
for _, entry := range s.registry {
if entry.peer != peer || !entry.row.Collectible() {
continue
}
if entry.row.SortOrder >= next {
next = entry.row.SortOrder + 1
}
}
if next > domain.MaxUsernameSortOrder {
next = domain.MaxUsernameSortOrder
}
return next
}
// attachLocked projects an owned asset into the registry. Callers check
// occupancy and the per-peer bound first, exactly like the PostgreSQL path does
// before it hits the unique index.
func (s *CollectibleUsernameStore) attachLocked(asset domain.CollectibleUsername, peer domain.Peer) {
s.registry[strings.ToLower(asset.Username)] = collectibleRegistryRow{
peer: peer,
row: domain.Username{
Username: asset.Username,
Active: true,
Editable: false,
SortOrder: s.nextSortOrderLocked(peer),
CollectibleID: asset.ID,
},
}
}
// detachLocked removes the registry row backed by the asset, leaving the peer's
// editable slot and its other collectibles untouched.
func (s *CollectibleUsernameStore) detachLocked(collectibleID int64) {
for key, entry := range s.registry {
if entry.row.CollectibleID == collectibleID {
delete(s.registry, key)
return
}
}
}
// clearEditableLocked drops the peer's editable row, keeping the one-editable-row
// index true by construction.
func (s *CollectibleUsernameStore) clearEditableLocked(peer domain.Peer) bool {
for key, entry := range s.registry {
if entry.peer == peer && entry.row.Editable {
delete(s.registry, key)
return true
}
}
return false
}
func (s *CollectibleUsernameStore) assetByNameLocked(username string) (domain.CollectibleUsername, error) {
if username == "" {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
id, ok := s.assetsByName[strings.ToLower(username)]
if !ok {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
asset, ok := s.assets[id]
if !ok {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
return asset, nil
}
// replayLocked resolves a command key onto the asset the recorded command
// touched. The provenance command index is global, so a replayed key is a no-op
// for every kind, just like the INSERT ... ON CONFLICT DO NOTHING path.
func (s *CollectibleUsernameStore) replayLocked(commandKey string) (domain.CollectibleUsername, bool) {
if commandKey == "" {
return domain.CollectibleUsername{}, false
}
id, ok := s.commands[commandKey]
if !ok {
return domain.CollectibleUsername{}, false
}
asset, ok := s.assets[id]
return asset, ok
}
func (s *CollectibleUsernameStore) recordTransferLocked(entry domain.CollectibleUsernameTransfer) {
entry.ID = s.nextTransferID
s.nextTransferID++
s.transfers[entry.CollectibleID] = append(s.transfers[entry.CollectibleID], entry)
if entry.CommandKey != "" {
s.commands[entry.CommandKey] = entry.CollectibleID
}
}
// validCollectibleUsernamePeer mirrors the peer_type CHECK: only real user and
// channel peers can hold a username.
func validCollectibleUsernamePeer(peer domain.Peer) bool {
switch peer.Type {
case domain.PeerTypeUser, domain.PeerTypeChannel:
return peer.ID > 0
default:
return false
}
}

View file

@ -0,0 +1,845 @@
package memory
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
var (
_ store.UsernameRegistryStore = (*CollectibleUsernameStore)(nil)
_ store.CollectibleUsernameStore = (*CollectibleUsernameStore)(nil)
)
func collectibleMintRequest(username string, owner domain.Peer, commandKey string) domain.MintCollectibleUsernameRequest {
return domain.MintCollectibleUsernameRequest{
Username: username,
Owner: owner,
PurchaseDate: time.Unix(1700000000, 0).UTC(),
Currency: domain.CollectibleCurrencyStars,
Amount: 2500,
Actor: "admin",
Reason: "unit test",
CommandKey: commandKey,
}
}
func mustMintCollectible(t *testing.T, s *CollectibleUsernameStore, username string, owner domain.Peer) domain.CollectibleUsername {
t.Helper()
asset, created, err := s.MintCollectibleUsername(context.Background(),
collectibleMintRequest(username, owner, "mint-"+username))
if err != nil || !created {
t.Fatalf("mint %s: created=%v err=%v", username, created, err)
}
return asset
}
func mustSetEditable(t *testing.T, s *CollectibleUsernameStore, peer domain.Peer, username string) {
t.Helper()
changed, err := s.SetEditableUsername(context.Background(), peer, username)
if err != nil || !changed {
t.Fatalf("set editable %s: changed=%v err=%v", username, changed, err)
}
}
// usernameRow finds a row by name the way the registry keys it: case-insensitively.
func usernameRow(t *testing.T, rows []domain.Username, username string) domain.Username {
t.Helper()
want := domain.NormalizeUsername(username)
for _, row := range rows {
if strings.EqualFold(row.Username, want) {
return row
}
}
t.Fatalf("username %q missing from %+v", username, rows)
return domain.Username{}
}
func TestCollectibleUsernameMint(t *testing.T) {
ctx := context.Background()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
channel := domain.Peer{Type: domain.PeerTypeChannel, ID: 2002}
tests := []struct {
name string
seed func(t *testing.T, s *CollectibleUsernameStore)
req domain.MintCollectibleUsernameRequest
wantErr error
wantCreated bool
check func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername)
}{
{
name: "into vault",
req: collectibleMintRequest("vaultname", domain.Peer{}, "cmd-vault"),
wantCreated: true,
check: func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername) {
if asset.Status != domain.CollectibleUsernameStatusVault || asset.Owned() {
t.Fatalf("asset=%+v", asset)
}
if asset.Owner != (domain.Peer{}) || asset.OriginalOwner != (domain.Peer{}) {
t.Fatalf("vault asset carries an owner: %+v", asset)
}
if asset.Version != 1 || asset.TransferCount != 0 {
t.Fatalf("asset=%+v", asset)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 0 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
},
},
{
name: "with owner",
req: collectibleMintRequest("OwnedName", holder, "cmd-owned"),
wantCreated: true,
check: func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername) {
if asset.Status != domain.CollectibleUsernameStatusOwned || !asset.Owned() {
t.Fatalf("asset=%+v", asset)
}
if asset.Owner != holder || asset.OriginalOwner != holder {
t.Fatalf("asset=%+v", asset)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 1 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
row := rows[0]
if row.Username != "OwnedName" || !row.Active || row.Editable ||
row.CollectibleID != asset.ID {
t.Fatalf("row=%+v", row)
}
},
},
{
name: "channel owner",
req: collectibleMintRequest("chanpost", channel, "cmd-chan"),
wantCreated: true,
check: func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername) {
rows, err := s.PeerUsernames(ctx, channel)
if err != nil || len(rows) != 1 || rows[0].CollectibleID != asset.ID {
t.Fatalf("rows=%+v err=%v", rows, err)
}
},
},
{
name: "command key replay",
seed: func(t *testing.T, s *CollectibleUsernameStore) {
if _, created, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("replayed", holder, "cmd-replay")); err != nil || !created {
t.Fatalf("seed mint created=%v err=%v", created, err)
}
},
req: collectibleMintRequest("replayed", holder, "cmd-replay"),
wantCreated: false,
check: func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername) {
if asset.Username != "replayed" || asset.ID != 1 {
t.Fatalf("replay returned %+v", asset)
}
log, err := s.CollectibleUsernameTransfers(ctx, asset.ID, 10)
if err != nil || len(log) != 1 || log[0].Kind != domain.CollectibleUsernameKindMint {
t.Fatalf("replay appended provenance: %+v err=%v", log, err)
}
},
},
{
name: "name held by another asset",
seed: func(t *testing.T, s *CollectibleUsernameStore) {
mustMintCollectible(t, s, "TakenName", holder)
},
req: collectibleMintRequest("takenname", channel, "cmd-dup"),
wantErr: domain.ErrUsernameOccupied,
},
{
name: "name held by an editable slot",
seed: func(t *testing.T, s *CollectibleUsernameStore) {
mustSetEditable(t, s, holder, "EditableOne")
},
req: collectibleMintRequest("editableone", channel, "cmd-editable"),
wantErr: domain.ErrUsernameOccupied,
},
{
name: "syntactically invalid",
req: collectibleMintRequest("ab", holder, "cmd-short"),
wantErr: domain.ErrUsernameInvalid,
},
{
name: "crypto pair without amount",
req: func() domain.MintCollectibleUsernameRequest {
req := collectibleMintRequest("cryptoname", holder, "cmd-crypto")
req.CryptoCurrency = domain.CollectibleCryptoCurrencyTON
return req
}(),
wantErr: domain.ErrCollectibleCurrencyInvalid,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := NewCollectibleUsernameStore()
if tc.seed != nil {
tc.seed(t, s)
}
asset, created, err := s.MintCollectibleUsername(ctx, tc.req)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err=%v want %v", err, tc.wantErr)
}
if created != tc.wantCreated {
t.Fatalf("created=%v want %v", created, tc.wantCreated)
}
if tc.wantErr != nil {
return
}
if tc.check != nil {
tc.check(t, s, asset)
}
})
}
}
func TestCollectibleUsernamePeerLimit(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
for i := 0; i < domain.MaxPeerCollectibleUsernames; i++ {
mustMintCollectible(t, s, fmt.Sprintf("holder%04d", i), holder)
}
if _, _, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("overflow", holder, "cmd-overflow")); !errors.Is(err, domain.ErrCollectibleUsernameLimit) {
t.Fatalf("mint over the limit err=%v", err)
}
// The rejected mint left no asset behind, so the name is still free.
if _, err := s.CollectibleUsername(ctx, "overflow"); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("rejected mint stored an asset: %v", err)
}
spare := mustMintCollectible(t, s, "sparename", other)
if _, _, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: spare.Username, To: holder, Actor: "admin", CommandKey: "cmd-limit-transfer",
}); !errors.Is(err, domain.ErrCollectibleUsernameLimit) {
t.Fatalf("transfer over the limit err=%v", err)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != domain.MaxPeerCollectibleUsernames {
t.Fatalf("rows=%d err=%v", len(rows), err)
}
// The failed transfer did not move the asset either.
stored, err := s.CollectibleUsername(ctx, "sparename")
if err != nil || stored.Owner != other {
t.Fatalf("stored=%+v err=%v", stored, err)
}
}
func TestCollectibleUsernameTransfer(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
mustSetEditable(t, s, holder, "holderslot")
owned := mustMintCollectible(t, s, "alphaone", holder)
vaulted := mustMintCollectible(t, s, "vaultone", domain.Peer{})
// Out of the vault: the asset gains a holder and its first original owner.
moved, changed, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "VaultOne", To: holder, Actor: "admin", CommandKey: "cmd-vault-out",
})
if err != nil || !changed {
t.Fatalf("transfer out of vault changed=%v err=%v", changed, err)
}
if moved.ID != vaulted.ID || moved.Owner != holder || moved.OriginalOwner != holder ||
moved.TransferCount != 1 || moved.Version != vaulted.Version+1 {
t.Fatalf("moved=%+v", moved)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 3 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
// The editable slot stays first, untouched and editable.
if rows[0].Username != "holderslot" || !rows[0].Editable || !rows[0].Active ||
rows[0].CollectibleID != 0 {
t.Fatalf("editable row=%+v", rows[0])
}
if rows[1].Username != "alphaone" || rows[2].Username != "vaultone" {
t.Fatalf("collectible order=%+v", rows)
}
// Replaying the command key is a no-op.
replay, changed, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "vaultone", To: other, Actor: "admin", CommandKey: "cmd-vault-out",
})
if err != nil || changed || replay.Owner != holder {
t.Fatalf("replay=%+v changed=%v err=%v", replay, changed, err)
}
// Handing an asset to its current holder changes nothing.
same, changed, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "alphaone", To: holder, Actor: "admin", CommandKey: "cmd-noop",
})
if err != nil || changed || same.Version != owned.Version {
t.Fatalf("no-op transfer=%+v changed=%v err=%v", same, changed, err)
}
// Between peers: the previous holder loses the registry row, the editable
// slot survives, and the original owner is preserved.
handed, changed, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "alphaone", To: other, Actor: "admin", CommandKey: "cmd-handover",
})
if err != nil || !changed {
t.Fatalf("handover changed=%v err=%v", changed, err)
}
if handed.Owner != other || handed.OriginalOwner != holder || handed.TransferCount != 1 {
t.Fatalf("handed=%+v", handed)
}
rows, err = s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 2 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
if usernameRow(t, rows, "holderslot").Editable != true {
t.Fatalf("editable slot lost: %+v", rows)
}
for _, row := range rows {
if row.Username == "alphaone" {
t.Fatalf("old owner kept the registry row: %+v", rows)
}
}
batch, err := s.PeerUsernamesBatch(ctx, []domain.Peer{holder, other, {Type: domain.PeerTypeUser, ID: 9}})
if err != nil || len(batch) != 2 {
t.Fatalf("batch=%+v err=%v", batch, err)
}
if len(batch[other]) != 1 || batch[other][0].Username != "alphaone" ||
batch[other][0].SortOrder != 0 {
t.Fatalf("new owner rows=%+v", batch[other])
}
if _, _, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "missingone", To: other, Actor: "admin",
}); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("transfer of unknown asset err=%v", err)
}
log, err := s.CollectibleUsernameTransfers(ctx, owned.ID, 10)
if err != nil || len(log) != 2 {
t.Fatalf("provenance=%+v err=%v", log, err)
}
if log[0].Kind != domain.CollectibleUsernameKindTransfer || log[0].From != holder || log[0].To != other {
t.Fatalf("newest provenance=%+v", log[0])
}
if log[1].Kind != domain.CollectibleUsernameKindMint || log[1].To != holder {
t.Fatalf("oldest provenance=%+v", log[1])
}
}
func TestCollectibleUsernameRevokeAndBurn(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
revoked := mustMintCollectible(t, s, "revokeme", holder)
burned := mustMintCollectible(t, s, "burnme", holder)
asset, changed, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "RevokeMe", Actor: "admin", Reason: "abuse", CommandKey: "cmd-revoke",
})
if err != nil || !changed {
t.Fatalf("revoke changed=%v err=%v", changed, err)
}
if asset.Status != domain.CollectibleUsernameStatusVault || asset.Owned() ||
asset.Owner != (domain.Peer{}) || asset.OriginalOwner != holder ||
asset.Version != revoked.Version+1 {
t.Fatalf("revoked asset=%+v", asset)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 1 || rows[0].Username != "burnme" {
t.Fatalf("rows=%+v err=%v", rows, err)
}
// Back in the vault the asset still owns its name: nobody else can take it.
if _, err := s.SetEditableUsername(ctx, other, "revokeme"); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("revoked name became claimable: %v", err)
}
if _, _, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("revokeme", other, "cmd-remint")); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("revoked name was re-minted: %v", err)
}
// Replay and a second revoke of a vault asset.
if replay, changed, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "revokeme", Actor: "admin", CommandKey: "cmd-revoke",
}); err != nil || changed || replay.Version != asset.Version {
t.Fatalf("replay=%+v changed=%v err=%v", replay, changed, err)
}
if _, _, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "revokeme", Actor: "admin", CommandKey: "cmd-revoke-again",
}); !errors.Is(err, domain.ErrCollectibleUsernameNotOwned) {
t.Fatalf("revoke of an unowned asset err=%v", err)
}
dead, changed, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "burnme", Burn: true, Actor: "admin", CommandKey: "cmd-burn",
})
if err != nil || !changed {
t.Fatalf("burn changed=%v err=%v", changed, err)
}
if dead.Status != domain.CollectibleUsernameStatusBurned || dead.Owner != (domain.Peer{}) ||
dead.OriginalOwner != holder || dead.Version != burned.Version+1 {
t.Fatalf("burned asset=%+v", dead)
}
rows, err = s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 0 {
t.Fatalf("burn left registry rows: %+v err=%v", rows, err)
}
// The burn released the name: another peer may occupy it again.
if changed, err := s.SetEditableUsername(ctx, other, "BurnMe"); err != nil || !changed {
t.Fatalf("claim freed name changed=%v err=%v", changed, err)
}
claimed, err := s.PeerUsernames(ctx, other)
if err != nil || len(claimed) != 1 || claimed[0].Username != "BurnMe" || !claimed[0].Editable {
t.Fatalf("claimed=%+v err=%v", claimed, err)
}
// The burned asset row survives for provenance, so the name never mints twice.
if _, _, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("burnme", holder, "cmd-burn-remint")); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("burned name was re-minted: %v", err)
}
for _, err := range []error{
mustErr(s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "burnme", To: other, Actor: "admin", CommandKey: "cmd-burn-transfer",
})),
mustErr(s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "burnme", Actor: "admin", CommandKey: "cmd-burn-revoke",
})),
} {
if !errors.Is(err, domain.ErrCollectibleUsernameBurned) {
t.Fatalf("mutation of a burned asset err=%v", err)
}
}
log, err := s.CollectibleUsernameTransfers(ctx, dead.ID, 10)
if err != nil || len(log) != 2 || log[0].Kind != domain.CollectibleUsernameKindBurn ||
log[0].From != holder {
t.Fatalf("burn provenance=%+v err=%v", log, err)
}
listed, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
Status: domain.CollectibleUsernameStatusBurned,
})
if err != nil || len(listed) != 1 || listed[0].ID != dead.ID {
t.Fatalf("listed=%+v err=%v", listed, err)
}
byID, err := s.CollectibleUsernameByID(ctx, dead.ID)
if err != nil || byID.Username != "burnme" {
t.Fatalf("byID=%+v err=%v", byID, err)
}
}
func mustErr[T any](_ T, _ bool, err error) error { return err }
func TestCollectibleUsernameToggle(t *testing.T) {
ctx := context.Background()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
lonely := domain.Peer{Type: domain.PeerTypeUser, ID: 1003}
tests := []struct {
name string
peer domain.Peer
username string
active bool
wantErr error
wantChanged bool
wantActive bool
}{
{name: "deactivate collectible", peer: holder, username: "alphaone", active: false, wantChanged: true},
{name: "activating an already active row", peer: holder, username: "ALPHAONE", active: true, wantChanged: false, wantActive: true},
{name: "editable slot is off limits", peer: holder, username: "holderslot", active: false, wantErr: domain.ErrUsernameNotCollectible, wantActive: true},
{name: "unknown username", peer: holder, username: "nothere", active: false, wantErr: domain.ErrUsernameNotOccupied},
{name: "last active collectible may be deactivated", peer: lonely, username: "lonelyone", active: false, wantChanged: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := NewCollectibleUsernameStore()
mustSetEditable(t, s, holder, "holderslot")
mustMintCollectible(t, s, "alphaone", holder)
mustMintCollectible(t, s, "betatwo", holder)
mustMintCollectible(t, s, "lonelyone", lonely)
changed, err := s.SetUsernameActive(ctx, tc.peer, tc.username, tc.active)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err=%v want %v", err, tc.wantErr)
}
if changed != tc.wantChanged {
t.Fatalf("changed=%v want %v", changed, tc.wantChanged)
}
if tc.username == "nothere" {
return
}
rows, err := s.PeerUsernames(ctx, tc.peer)
if err != nil {
t.Fatal(err)
}
row := usernameRow(t, rows, tc.username)
if row.Active != tc.wantActive {
t.Fatalf("row=%+v want active=%v", row, tc.wantActive)
}
})
}
t.Run("deactivate all keeps the editable slot", func(t *testing.T) {
s := NewCollectibleUsernameStore()
mustSetEditable(t, s, holder, "holderslot")
mustMintCollectible(t, s, "alphaone", holder)
mustMintCollectible(t, s, "betatwo", holder)
changed, err := s.DeactivateAllUsernames(ctx, holder)
if err != nil || !changed {
t.Fatalf("deactivate all changed=%v err=%v", changed, err)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 3 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
if !rows[0].Editable || !rows[0].Active {
t.Fatalf("editable row=%+v", rows[0])
}
if rows[1].Active || rows[2].Active {
t.Fatalf("collectibles still active: %+v", rows)
}
if changed, err := s.DeactivateAllUsernames(ctx, holder); err != nil || changed {
t.Fatalf("second deactivate changed=%v err=%v", changed, err)
}
})
}
func TestCollectibleUsernameReorder(t *testing.T) {
ctx := context.Background()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
tests := []struct {
name string
order []string
wantErr error
wantChanged bool
wantOrder []string
}{
{
// Clients send the whole active list, editable slot included.
name: "valid permutation",
order: []string{"holderslot", "gammathree", "@AlphaOne", "betatwo"},
wantChanged: true,
wantOrder: []string{"holderslot", "gammathree", "alphaone", "betatwo"},
},
{
name: "identity permutation",
order: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
wantChanged: false,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
// The editable slot is reorderable: a collectible may be made primary.
name: "collectible ahead of the editable slot",
order: []string{"gammathree", "holderslot", "alphaone", "betatwo"},
wantChanged: true,
wantOrder: []string{"gammathree", "holderslot", "alphaone", "betatwo"},
},
{
name: "partial order",
order: []string{"holderslot", "alphaone"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
name: "active editable slot omitted",
order: []string{"alphaone", "betatwo", "gammathree"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
name: "duplicate entry",
order: []string{"holderslot", "alphaone", "alphaone", "betatwo"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
name: "unknown username",
order: []string{"holderslot", "alphaone", "betatwo", "nothere"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
name: "garbage input",
order: []string{"holderslot", "", "@", "alphaone"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := NewCollectibleUsernameStore()
mustSetEditable(t, s, holder, "holderslot")
mustMintCollectible(t, s, "alphaone", holder)
mustMintCollectible(t, s, "betatwo", holder)
mustMintCollectible(t, s, "gammathree", holder)
changed, err := s.ReorderUsernames(ctx, holder, tc.order)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err=%v want %v", err, tc.wantErr)
}
if changed != tc.wantChanged {
t.Fatalf("changed=%v want %v", changed, tc.wantChanged)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil {
t.Fatal(err)
}
want := tc.wantOrder
if len(rows) != len(want) {
t.Fatalf("rows=%+v want %v", rows, want)
}
for i, name := range want {
if rows[i].Username != name {
t.Fatalf("rows=%+v want %v", rows, want)
}
}
})
}
// A peer whose only username is the editable slot: sending just that name is
// the identity order, and sending nothing at all is the no-op every client
// gets when it reconciles an empty collectible list.
t.Run("editable slot only", func(t *testing.T) {
s := NewCollectibleUsernameStore()
mustSetEditable(t, s, holder, "holderslot")
if changed, err := s.ReorderUsernames(ctx, holder, []string{"holderslot"}); err != nil || changed {
t.Fatalf("editable-only order: changed=%v err=%v", changed, err)
}
})
t.Run("no usernames at all", func(t *testing.T) {
s := NewCollectibleUsernameStore()
if changed, err := s.ReorderUsernames(ctx, holder, nil); err != nil || changed {
t.Fatalf("changed=%v err=%v", changed, err)
}
})
}
func TestCollectibleUsernameRegistryUniqueness(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
mustSetEditable(t, s, holder, "holderslot")
// One editable row per peer: setting a new one replaces the old.
mustSetEditable(t, s, holder, "secondslot")
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 1 || rows[0].Username != "secondslot" {
t.Fatalf("rows=%+v err=%v", rows, err)
}
// The released name is free again, case-insensitively.
if changed, err := s.SetEditableUsername(ctx, other, "HOLDERSLOT"); err != nil || !changed {
t.Fatalf("changed=%v err=%v", changed, err)
}
if _, err := s.SetEditableUsername(ctx, holder, "holderslot"); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("occupied name reused: %v", err)
}
if changed, err := s.SetEditableUsername(ctx, other, ""); err != nil || !changed {
t.Fatalf("clear editable changed=%v err=%v", changed, err)
}
if rows, err := s.PeerUsernames(ctx, other); err != nil || len(rows) != 0 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
// A vault asset owns its name even without a registry row, so the editable
// slot cannot take it and the asset stays handable.
vaulted := mustMintCollectible(t, s, "vaultname", domain.Peer{})
if _, err := s.SetEditableUsername(ctx, other, "VaultName"); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("vault name became claimable: %v", err)
}
if moved, _, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "vaultname", To: other, Actor: "admin", CommandKey: "cmd-vault-out",
}); err != nil || moved.ID != vaulted.ID || moved.Owner != other {
t.Fatalf("moved=%+v err=%v", moved, err)
}
// Returned slices are copies: mutating them cannot change stored state.
rows, err = s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 1 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
rows[0].Username = "mutated"
rows[0].Active = false
again, err := s.PeerUsernames(ctx, holder)
if err != nil || again[0].Username != "secondslot" || !again[0].Active {
t.Fatalf("stored state mutated through the returned slice: %+v err=%v", again, err)
}
}
func TestCollectibleUsernameListingAndPaging(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
first := mustMintCollectible(t, s, "alphaone", holder)
second := mustMintCollectible(t, s, "alphatwo", other)
third := mustMintCollectible(t, s, "betathree", domain.Peer{})
all, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{})
if err != nil || len(all) != 3 || all[0].ID != third.ID || all[2].ID != first.ID {
t.Fatalf("all=%+v err=%v", all, err)
}
page, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{Limit: 2})
if err != nil || len(page) != 2 || page[0].ID != third.ID {
t.Fatalf("page=%+v err=%v", page, err)
}
next, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
BeforeID: page[len(page)-1].ID, Limit: 2,
})
if err != nil || len(next) != 1 || next[0].ID != first.ID {
t.Fatalf("next=%+v err=%v", next, err)
}
owned, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{Owner: other})
if err != nil || len(owned) != 1 || owned[0].ID != second.ID {
t.Fatalf("owned=%+v err=%v", owned, err)
}
matched, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{Query: "ALPHA"})
if err != nil || len(matched) != 2 {
t.Fatalf("matched=%+v err=%v", matched, err)
}
vault, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
Status: domain.CollectibleUsernameStatusVault,
})
if err != nil || len(vault) != 1 || vault[0].ID != third.ID {
t.Fatalf("vault=%+v err=%v", vault, err)
}
if _, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
Status: domain.CollectibleUsernameStatus("gone"),
}); !errors.Is(err, domain.ErrCollectibleUsernameStateInvalid) {
t.Fatalf("invalid status filter err=%v", err)
}
if _, err := s.CollectibleUsername(ctx, "@AlphaOne"); err != nil {
t.Fatalf("lookup by display form: %v", err)
}
if _, err := s.CollectibleUsername(ctx, ""); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("empty lookup err=%v", err)
}
if _, err := s.CollectibleUsernameByID(ctx, 4242); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("unknown id err=%v", err)
}
}
// TestCollectibleUsernameReissueAfterBurn covers migration 0152: burning retires
// the asset but releases the name, so the same name can be issued again while the
// burned rows stay as provenance.
func TestCollectibleUsernameReissueAfterBurn(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 4001}
first := mustMintCollectible(t, s, "Nfts", holder)
if _, _, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "nfts", Burn: true, Actor: "admin", Reason: "retire", CommandKey: "burn-1",
}); err != nil {
t.Fatalf("burn: %v", err)
}
second, created, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("NFTS", holder, "mint-again-1"))
if err != nil || !created {
t.Fatalf("reissue after burn: created=%v err=%v", created, err)
}
if second.ID == first.ID {
t.Fatalf("reissue reused asset id %d", second.ID)
}
if second.Status != domain.CollectibleUsernameStatusOwned {
t.Fatalf("reissued status = %q, want owned", second.Status)
}
// The name now resolves to the live asset, not to either burned row.
live, err := s.CollectibleUsername(ctx, "nfts")
if err != nil {
t.Fatalf("lookup after reissue: %v", err)
}
if live.ID != second.ID {
t.Fatalf("lookup id = %d, want the live asset %d", live.ID, second.ID)
}
// The burned row is still readable by identity: it is the provenance record.
burned, err := s.CollectibleUsernameByID(ctx, first.ID)
if err != nil || burned.Status != domain.CollectibleUsernameStatusBurned {
t.Fatalf("burned row = %+v err=%v", burned, err)
}
// A second burn releases the name again, so the cycle repeats.
if _, _, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "nfts", Burn: true, Actor: "admin", Reason: "retire", CommandKey: "burn-2",
}); err != nil {
t.Fatalf("second burn: %v", err)
}
if _, created, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("nfts", domain.Peer{}, "mint-again-2")); err != nil || !created {
t.Fatalf("second reissue: created=%v err=%v", created, err)
}
// A live asset still blocks a mint, burned history or not.
if _, _, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("nfts", domain.Peer{}, "mint-again-3")); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("mint over live asset err = %v, want ErrUsernameOccupied", err)
}
}
func TestCollectibleUsernameDelete(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 4101}
mustSetEditable(t, s, holder, "holder_main")
asset := mustMintCollectible(t, s, "Gone", holder)
deleted, err := s.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
Username: "@gone", Actor: "admin", Reason: "issued by mistake", CommandKey: "del-1",
})
if err != nil || !deleted {
t.Fatalf("delete: deleted=%v err=%v", deleted, err)
}
if _, err := s.CollectibleUsernameByID(ctx, asset.ID); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("asset after delete err = %v, want not found", err)
}
if _, err := s.CollectibleUsername(ctx, "gone"); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("lookup after delete err = %v, want not found", err)
}
log, err := s.CollectibleUsernameTransfers(ctx, asset.ID, 10)
if err != nil || len(log) != 0 {
t.Fatalf("provenance after delete = %d rows err=%v, want none", len(log), err)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil {
t.Fatalf("peer usernames: %v", err)
}
if len(rows) != 1 || !rows[0].Editable {
t.Fatalf("owner rows after delete = %+v, want only the editable slot", rows)
}
// The name is completely free afterwards, for a collectible or an editable slot.
if _, created, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("gone", domain.Peer{}, "mint-after-delete")); err != nil || !created {
t.Fatalf("mint after delete: created=%v err=%v", created, err)
}
// A repeat is a no-op rather than an error: the record a command key would
// resolve to is gone, so idempotency degrades to "nothing live left".
if _, _, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "gone", Burn: true, Actor: "admin", Reason: "retire", CommandKey: "burn-after-delete",
}); err != nil {
t.Fatalf("burn reissued asset: %v", err)
}
deleted, err = s.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
Username: "gone", Actor: "admin", Reason: "again", CommandKey: "del-2",
})
if err != nil || deleted {
t.Fatalf("delete of burned-only name = %v err=%v, want (false, nil)", deleted, err)
}
deleted, err = s.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
Username: "never_issued", Actor: "admin", Reason: "again", CommandKey: "del-3",
})
if err != nil || deleted {
t.Fatalf("delete of unknown name = %v err=%v, want (false, nil)", deleted, err)
}
}

View file

@ -112,7 +112,7 @@ func (s *CommunityStore) viewLocked(userID, id int64) (domain.CommunityView, err
cm, ok := s.channels.members[l.Peer.ID][userID]
joined = ok && cm.Status == domain.ChannelMemberActive
if channel, ok := s.channels.channels[l.Peer.ID]; ok {
inherentlyViewable = publicPreviewableChannel(channel)
inherentlyViewable = s.channels.publicPreviewableChannelLocked(channel)
}
s.channels.mu.RUnlock()
} else if l.Peer.Type == domain.PeerTypeUser && s.dialogs != nil {

View file

@ -138,9 +138,6 @@ func (s *ContactStore) Upsert(_ context.Context, userID int64, input domain.Cont
contact.User.EmojiStatusUntil = existing.User.EmojiStatusUntil
contact.CloseFriend = existing.CloseFriend
contact.User.CloseFriend = existing.CloseFriend || existing.User.CloseFriend
if contact.Phone == "" {
contact.User.Phone = existing.User.Phone
}
if contact.FirstName == "" {
contact.User.FirstName = existing.User.FirstName
}

View file

@ -33,7 +33,7 @@ func (s *MessageStore) DeleteMessages(_ context.Context, req domain.DeleteMessag
if req.Revoke && len(revokeUIDs) > 0 {
deleted = append(deleted, s.deleteMemoryMessagesByUIDLocked(revokeUIDs, req.OwnerUserID)...)
}
return s.finishMemoryDeleteLocked(res, deleted, req.Date, false), nil
return s.finishMemoryDeleteLocked(res, deleted, req.Date, nil), nil
}
type deletedMemoryMessage struct {
@ -45,70 +45,177 @@ type deletedMemoryMessage struct {
randomID int64
}
func (s *MessageStore) finishMemoryDeleteLocked(res domain.DeleteMessagesResult, deleted []deletedMemoryMessage, date int, preserveEmptyDialogs bool) domain.DeleteMessagesResult {
if len(deleted) == 0 {
type memoryHistoryClearAnchor struct {
message domain.Message
materialized bool
}
func (s *MessageStore) finishMemoryDeleteLocked(res domain.DeleteMessagesResult, deleted []deletedMemoryMessage, date int, anchors map[int64]memoryHistoryClearAnchor) domain.DeleteMessagesResult {
if len(deleted) == 0 && len(anchors) == 0 {
return res
}
idsByOwner := make(map[int64][]int)
peersByOwner := make(map[int64]map[domain.Peer]struct{})
for _, row := range deleted {
if byMessage := s.savedMessageTags[row.userID]; byMessage != nil {
delete(byMessage, row.id)
if len(byMessage) == 0 {
delete(s.savedMessageTags, row.userID)
}
}
idsByOwner[row.userID] = append(idsByOwner[row.userID], row.id)
if peersByOwner[row.userID] == nil {
peersByOwner[row.userID] = make(map[domain.Peer]struct{})
}
peersByOwner[row.userID][row.peer] = struct{}{}
}
if s.dialogs != nil {
s.dialogs.mu.Lock()
for userID, peers := range peersByOwner {
for peer := range peers {
s.rebuildMemoryDialogLocked(userID, peer, preserveEmptyDialogs)
}
for userID, anchor := range anchors {
if peersByOwner[userID] == nil {
peersByOwner[userID] = make(map[domain.Peer]struct{})
}
s.dialogs.mu.Unlock()
peersByOwner[userID][anchor.message.Peer] = struct{}{}
}
ownerIDs := make([]int64, 0, len(idsByOwner))
ownerSet := make(map[int64]struct{}, len(idsByOwner)+len(anchors))
for userID := range idsByOwner {
ownerSet[userID] = struct{}{}
}
for userID, anchor := range anchors {
if !anchor.materialized {
ownerSet[userID] = struct{}{}
}
}
ownerIDs := make([]int64, 0, len(ownerSet))
for userID := range ownerSet {
ownerIDs = append(ownerIDs, userID)
}
sort.Slice(ownerIDs, func(i, j int) bool { return ownerIDs[i] < ownerIDs[j] })
for _, userID := range ownerIDs {
ids := normalizeMemoryMessageIDs(idsByOwner[userID])
if len(ids) == 0 {
anchor, hasAnchor := anchors[userID]
materializeAnchor := hasAnchor && !anchor.materialized
totalPtsCount := len(ids)
if materializeAnchor {
totalPtsCount += 2
}
if totalPtsCount == 0 {
continue
}
pts := s.nextPtsNLocked(userID, len(ids))
event := domain.UpdateEvent{
pts := s.nextPtsNLocked(userID, totalPtsCount)
cursor := pts - totalPtsCount
item := domain.DeletedMessagesForUser{
UserID: userID,
Type: domain.UpdateEventDeleteMessages,
MessageIDs: ids,
Pts: pts,
PtsCount: len(ids),
Date: date,
MessageIDs: ids,
PtsCount: totalPtsCount,
Events: make([]domain.UpdateEvent, 0, 3),
}
for _, row := range deleted {
if row.userID != userID || row.messageSenderID != userID || row.randomID == 0 || row.privateMessageID == 0 {
continue
if len(ids) > 0 {
cursor += len(ids)
event := domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventDeleteMessages,
Pts: cursor,
PtsCount: len(ids),
Date: date,
MessageIDs: ids,
}
key := privateSendDedupKey{senderUserID: userID, randomID: row.randomID}
record, ok := s.privateSendDedup[key]
if !ok {
continue
for _, row := range deleted {
if row.userID != userID || row.messageSenderID != userID || row.randomID == 0 || row.privateMessageID == 0 {
continue
}
key := privateSendDedupKey{senderUserID: userID, randomID: row.randomID}
record, ok := s.privateSendDedup[key]
if !ok {
continue
}
cloned := cloneUpdateEvent(event)
record.senderDeleteEvent = &cloned
s.privateSendDedup[key] = record
}
cloned := cloneUpdateEvent(event)
record.senderDeleteEvent = &cloned
s.privateSendDedup[key] = record
item.Event = event
item.Events = append(item.Events, event)
}
res.Deleted = append(res.Deleted, domain.DeletedMessagesForUser{
UserID: userID,
MessageIDs: ids,
Event: event,
})
if materializeAnchor {
readPts := cursor + 1
editPts := readPts + 1
msg := domain.NewHistoryClearMessage(
userID,
anchor.message.Peer,
anchor.message.ID,
anchor.message.UID,
anchor.message.Date,
editPts,
)
for i := range s.m[userID] {
if s.m[userID][i].ID == anchor.message.ID && s.m[userID][i].Peer == anchor.message.Peer {
s.m[userID][i] = msg
break
}
}
if byMessage := s.savedMessageTags[userID]; byMessage != nil {
delete(byMessage, anchor.message.ID)
if len(byMessage) == 0 {
delete(s.savedMessageTags, userID)
}
}
readEvent := domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventReadHistoryInbox,
Pts: readPts,
PtsCount: 1,
Date: date,
Peer: anchor.message.Peer,
MaxID: anchor.message.ID,
StillUnreadCount: 0,
}
editEvent := domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventEditMessage,
Pts: editPts,
PtsCount: 1,
Date: date,
Message: cloneMessage(msg),
}
item.Events = append(item.Events, readEvent, editEvent)
cursor = editPts
}
if s.dialogs != nil {
s.dialogs.mu.Lock()
for peer := range peersByOwner[userID] {
s.rebuildMemoryDialogLocked(userID, peer)
}
if materializeAnchor {
s.advanceMemoryHistoryClearDialogLocked(userID, anchor.message.Peer, anchor.message.ID)
}
s.dialogs.mu.Unlock()
}
if cursor != pts {
panic(fmt.Sprintf("memory delete history pts cursor %d does not reach reserved pts %d", cursor, pts))
}
res.Deleted = append(res.Deleted, item)
}
return res
}
func (s *MessageStore) rebuildMemoryDialogLocked(userID int64, peer domain.Peer, preserveEmpty bool) {
func (s *MessageStore) advanceMemoryHistoryClearDialogLocked(userID int64, peer domain.Peer, maxID int) {
list := s.dialogs.m[userID]
for i := range list.Dialogs {
if list.Dialogs[i].Peer != peer {
continue
}
if list.Dialogs[i].ReadInboxMaxID < maxID {
list.Dialogs[i].ReadInboxMaxID = maxID
}
list.Dialogs[i].UnreadCount = 0
list.Dialogs[i].UnreadMark = false
list.Dialogs[i].UnreadMentions = 0
list.Dialogs[i].UnreadReactions = 0
break
}
s.dialogs.m[userID] = list
}
func (s *MessageStore) rebuildMemoryDialogLocked(userID int64, peer domain.Peer) {
list := s.dialogs.m[userID]
topID := 0
topDate := 0
@ -129,22 +236,6 @@ func (s *MessageStore) rebuildMemoryDialogLocked(userID int64, peer domain.Peer,
continue
}
if topID == 0 {
if preserveEmpty {
oldTop := dialog.TopMessage
dialog.TopMessage = 0
dialog.TopMessageDate = 0
if dialog.ReadInboxMaxID < oldTop {
dialog.ReadInboxMaxID = oldTop
}
if dialog.ReadOutboxMaxID < oldTop {
dialog.ReadOutboxMaxID = oldTop
}
dialog.UnreadCount = 0
dialog.UnreadMark = false
dialog.UnreadMentions = 0
dialog.UnreadReactions = 0
dialogs = append(dialogs, dialog)
}
continue
}
for _, msg := range s.m[userID] {

View file

@ -20,6 +20,9 @@ func (s *MessageStore) ForwardPrivateMessages(ctx context.Context, req domain.Fo
if req.Date == 0 {
req.Date = int(time.Now().Unix())
}
if s.privateNoForwardsEnabled(req.OwnerUserID, req.FromPeer.ID) {
return res, domain.ErrChatForwardsRestricted
}
s.mu.RLock()
sources := make([]domain.Message, 0, len(req.MessageIDs))
for _, id := range req.MessageIDs {

View file

@ -114,10 +114,18 @@ func cloneRequestedPeerMedia(media *domain.MessageMedia) *domain.MessageMedia {
video.Attributes = append([]domain.DocumentAttribute(nil), media.LivePhotoVideo.Attributes...)
clone.LivePhotoVideo = &video
}
if media.ServiceAction == nil || media.ServiceAction.RequestedPeer == nil {
if media.ServiceAction == nil {
return &clone
}
action := *media.ServiceAction
if media.ServiceAction.NoForwards != nil {
noForwards := *media.ServiceAction.NoForwards
action.NoForwards = &noForwards
}
if media.ServiceAction.RequestedPeer == nil {
clone.ServiceAction = &action
return &clone
}
requested := *media.ServiceAction.RequestedPeer
requested.Peers = append([]domain.Peer(nil), requested.Peers...)
requested.Details = append([]domain.MessageRequestedPeerDetails(nil), requested.Details...)

View file

@ -4,8 +4,9 @@ import (
"context"
"sort"
"strings"
"telesrv/internal/domain"
"time"
"telesrv/internal/domain"
)
func (s *MessageStore) GetByIDs(_ context.Context, userID int64, ids []int) (domain.MessageList, error) {
@ -231,30 +232,66 @@ func (s *MessageStore) DeleteHistory(_ context.Context, req domain.DeleteHistory
}
return true
}
var anchors map[int64]memoryHistoryClearAnchor
fullJustClear := req.JustClear && req.MaxID <= 0 && req.MinDate <= 0 && req.MaxDate <= 0
if fullJustClear {
anchors = make(map[int64]memoryHistoryClearAnchor, 2)
if anchor, found := s.memoryHistoryClearAnchorLocked(req.OwnerUserID, req.Peer); found {
anchors[req.OwnerUserID] = anchor
}
if req.Revoke && req.Peer.ID != req.OwnerUserID {
peer := domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}
if anchor, found := s.memoryHistoryClearAnchorLocked(req.Peer.ID, peer); found {
anchors[req.Peer.ID] = anchor
}
}
}
deleted, revokeUIDs, more := s.deleteMemoryMessagesLocked(req.OwnerUserID, domain.MaxDeleteHistoryBatch, func(msg domain.Message) bool {
if anchor, ok := anchors[req.OwnerUserID]; ok && msg.ID == anchor.message.ID {
return false
}
return msg.Peer == req.Peer && (req.MaxID <= 0 || msg.ID <= req.MaxID) && inDateRange(msg)
})
if req.Revoke {
if len(revokeUIDs) > 0 {
if req.MaxID > 0 && len(revokeUIDs) > 0 {
deleted = append(deleted, s.deleteMemoryMessagesByUIDLocked(revokeUIDs, req.OwnerUserID)...)
}
// 与 PG 同语义:全量/按日期的双向清史直扫对端残余,我方早已
// 单向删除的消息不能在对端残留。
if req.MaxID <= 0 && req.Peer.ID != req.OwnerUserID {
peerDeleted, _, peerMore := s.deleteMemoryMessagesLocked(req.Peer.ID, domain.MaxDeleteHistoryBatch, func(msg domain.Message) bool {
if anchor, ok := anchors[req.Peer.ID]; ok && msg.ID == anchor.message.ID {
return false
}
return msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}) && inDateRange(msg)
})
deleted = append(deleted, peerDeleted...)
more = more || peerMore
}
}
res = s.finishMemoryDeleteLocked(res, deleted, req.Date, req.JustClear)
res = s.finishMemoryDeleteLocked(res, deleted, req.Date, anchors)
if more {
res.Offset = 1
}
return res, nil
}
func (s *MessageStore) memoryHistoryClearAnchorLocked(userID int64, peer domain.Peer) (memoryHistoryClearAnchor, bool) {
var top domain.Message
for _, msg := range s.m[userID] {
if msg.Peer == peer && msg.ID > top.ID {
top = msg
}
}
if top.ID == 0 {
return memoryHistoryClearAnchor{}, false
}
return memoryHistoryClearAnchor{
message: cloneMessage(top),
materialized: domain.IsHistoryClearServiceMessage(top),
}, true
}
func filterMessageList(messages []domain.Message, filter domain.MessageFilter) domain.MessageList {
filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset)
sort.SliceStable(messages, func(i, j int) bool {
@ -282,6 +319,12 @@ func filterMessageList(messages []domain.Message, filter domain.MessageFilter) d
if query != "" && !strings.Contains(strings.ToLower(msg.Body), query) {
continue
}
if filter.MinDate > 0 && msg.Date <= filter.MinDate {
continue
}
if filter.MaxDate > 0 && msg.Date >= filter.MaxDate {
continue
}
if filter.MaxID > 0 && msg.ID >= filter.MaxID {
continue
}
@ -297,6 +340,9 @@ func filterMessageList(messages []domain.Message, filter domain.MessageFilter) d
if filter.SavedPeer.ID != 0 && msg.SavedPeer != filter.SavedPeer {
continue
}
if len(filter.SavedReactions) > 0 && !messageHasAnySavedTag(msg, filter.SavedReactions) {
continue
}
base = append(base, msg)
}
@ -316,6 +362,22 @@ func filterMessageList(messages []domain.Message, filter domain.MessageFilter) d
}
}
func messageHasAnySavedTag(msg domain.Message, wanted []domain.MessageReaction) bool {
if msg.Reactions == nil || !msg.Reactions.AsTags {
return false
}
have := make(map[string]struct{}, len(msg.Reactions.Results))
for _, result := range msg.Reactions.Results {
have[result.Reaction.Key()] = struct{}{}
}
for _, reaction := range wanted {
if _, ok := have[reaction.Key()]; ok {
return true
}
}
return false
}
func pageMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int) []domain.Message {
if limit <= 0 || len(base) == 0 {
return nil

View file

@ -0,0 +1,200 @@
package memory
import (
"context"
"time"
"telesrv/internal/domain"
)
type privateNoForwardsPair struct {
low int64
high int64
}
type memoryNoForwardsRequest struct {
privateMessageID int64
requesterUserID int64
responderUserID int64
expiresAt int
handled bool
}
func noForwardsPair(a, b int64) (privateNoForwardsPair, bool) {
if a <= 0 || b <= 0 || a == b {
return privateNoForwardsPair{}, false
}
if a > b {
a, b = b, a
}
return privateNoForwardsPair{low: a, high: b}, true
}
func (s *MessageStore) GetPrivateNoForwards(_ context.Context, viewerUserID, peerUserID int64) (domain.PrivateNoForwardsState, error) {
pair, ok := noForwardsPair(viewerUserID, peerUserID)
if !ok {
return domain.PrivateNoForwardsState{}, domain.ErrMessageIDInvalid
}
s.noForwardsMu.Lock()
defer s.noForwardsMu.Unlock()
state := s.privateNoForwards[pair]
state.UserLowID, state.UserHighID = pair.low, pair.high
return state, nil
}
func (s *MessageStore) TogglePrivateNoForwards(ctx context.Context, req domain.TogglePrivateNoForwardsRequest) (domain.TogglePrivateNoForwardsResult, error) {
pair, ok := noForwardsPair(req.ActorUserID, req.PeerUserID)
if !ok || req.RequestMsgID < 0 || req.RequestMsgID > domain.MaxMessageBoxID {
return domain.TogglePrivateNoForwardsResult{}, domain.ErrMessageIDInvalid
}
if req.Date == 0 {
req.Date = int(time.Now().Unix())
}
if req.RandomID == 0 {
req.RandomID = time.Now().UnixNano()
if req.RandomID == 0 {
req.RandomID = 1
}
}
s.noForwardsMu.Lock()
defer s.noForwardsMu.Unlock()
state := s.privateNoForwards[pair]
state.UserLowID, state.UserHighID = pair.low, pair.high
previousEnabled := state.Enabled()
var (
kind domain.MessageServiceActionKind
action domain.MessageNoForwardsAction
requestRecord *memoryNoForwardsRequest
requestUID int64
)
if req.RequestMsgID != 0 {
s.mu.RLock()
var source domain.Message
for _, msg := range s.m[req.ActorUserID] {
if msg.ID == req.RequestMsgID && msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: req.PeerUserID}) {
source = msg
break
}
}
if source.ID != 0 {
record := s.privateNoForwardsRequests[source.UID]
requestRecord = &record
requestUID = source.UID
}
s.mu.RUnlock()
if source.ID == 0 || requestRecord == nil || requestRecord.privateMessageID != source.UID ||
requestRecord.requesterUserID != req.PeerUserID || requestRecord.responderUserID != req.ActorUserID ||
requestRecord.handled || requestRecord.expiresAt <= req.Date {
return domain.TogglePrivateNoForwardsResult{}, domain.ErrNoForwardsRequestExpired
}
kind = domain.MessageServiceActionNoForwardsToggle
action = domain.MessageNoForwardsAction{PrevValue: previousEnabled, NewValue: req.Enabled}
if req.Enabled {
state.EnabledByUserID = req.ActorUserID
} else {
state.EnabledByUserID = 0
}
} else if req.Enabled {
if state.EnabledByUserID != 0 {
return domain.TogglePrivateNoForwardsResult{State: state}, nil
}
kind = domain.MessageServiceActionNoForwardsToggle
action = domain.MessageNoForwardsAction{PrevValue: false, NewValue: true}
state.EnabledByUserID = req.ActorUserID
} else {
switch state.EnabledByUserID {
case 0:
return domain.TogglePrivateNoForwardsResult{State: state}, nil
case req.ActorUserID:
kind = domain.MessageServiceActionNoForwardsToggle
action = domain.MessageNoForwardsAction{PrevValue: true, NewValue: false}
state.EnabledByUserID = 0
default:
kind = domain.MessageServiceActionNoForwardsRequest
action = domain.MessageNoForwardsAction{
PrevValue: true,
NewValue: false,
ExpiresAt: req.Date + domain.PrivateNoForwardsRequestExpirePeriod,
}
}
}
reply := (*domain.MessageReply)(nil)
if req.RequestMsgID != 0 {
reply = &domain.MessageReply{
MessageID: req.RequestMsgID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.PeerUserID},
}
}
send, err := s.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: req.ActorUserID,
RecipientUserID: req.PeerUserID,
RandomID: req.RandomID,
Silent: true,
Date: req.Date,
OriginAuthKeyID: req.OriginAuthKeyID,
OriginSessionID: req.OriginSessionID,
ReplyTo: reply,
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: kind,
NoForwards: &action,
},
},
})
if err != nil {
if req.RequestMsgID != 0 && err == domain.ErrReplyMessageIDInvalid {
return domain.TogglePrivateNoForwardsResult{}, domain.ErrNoForwardsRequestExpired
}
return domain.TogglePrivateNoForwardsResult{}, err
}
s.privateNoForwards[pair] = state
if kind == domain.MessageServiceActionNoForwardsRequest {
s.privateNoForwardsRequests[send.SenderMessage.UID] = memoryNoForwardsRequest{
privateMessageID: send.SenderMessage.UID,
requesterUserID: req.ActorUserID,
responderUserID: req.PeerUserID,
expiresAt: action.ExpiresAt,
}
}
if requestUID != 0 {
record := s.privateNoForwardsRequests[requestUID]
record.handled = true
s.privateNoForwardsRequests[requestUID] = record
s.markNoForwardsRequestExpired(requestUID)
}
return domain.TogglePrivateNoForwardsResult{State: state, Changed: true, Send: send}, nil
}
func (s *MessageStore) markNoForwardsRequestExpired(privateMessageID int64) {
s.mu.Lock()
defer s.mu.Unlock()
for ownerID, messages := range s.m {
for i := range messages {
action := messages[i].Media
if messages[i].UID != privateMessageID || action == nil || action.ServiceAction == nil ||
action.ServiceAction.Kind != domain.MessageServiceActionNoForwardsRequest ||
action.ServiceAction.NoForwards == nil {
continue
}
messages[i].Media = cloneRequestedPeerMedia(messages[i].Media)
messages[i].Media.ServiceAction.NoForwards.Expired = true
}
s.m[ownerID] = messages
}
}
func (s *MessageStore) privateNoForwardsEnabled(a, b int64) bool {
pair, ok := noForwardsPair(a, b)
if !ok {
return false
}
s.noForwardsMu.Lock()
defer s.noForwardsMu.Unlock()
return s.privateNoForwards[pair].Enabled()
}

View file

@ -0,0 +1,162 @@
package memory
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
)
func TestPrivateNoForwardsStateMachineAndForwardGate(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
const alice, bob int64 = 1001, 1002
enable, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: alice, PeerUserID: bob, Enabled: true, RandomID: 11, Date: 100,
})
if err != nil {
t.Fatalf("enable: %v", err)
}
if !enable.Changed || enable.State.EnabledByUserID != alice ||
enable.Send.SenderMessage.Pts != 1 || enable.Send.RecipientMessage.Pts != 1 ||
enable.Send.SenderMessage.NoForwards {
t.Fatalf("enable result = %+v", enable)
}
assertMemoryNoForwardsAction(t, enable.Send.SenderMessage, domain.MessageServiceActionNoForwardsToggle, false, true, false)
repeat, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: alice, PeerUserID: bob, Enabled: true, RandomID: 12, Date: 101,
})
if err != nil || repeat.Changed || repeat.State.EnabledByUserID != alice {
t.Fatalf("repeat enable = %+v err=%v, want no-op", repeat, err)
}
request, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: bob, PeerUserID: alice, Enabled: false, RandomID: 13, Date: 102,
})
if err != nil {
t.Fatalf("request disable: %v", err)
}
if request.State.EnabledByUserID != alice || request.Send.SenderMessage.Pts != 2 ||
request.Send.RecipientMessage.Pts != 2 {
t.Fatalf("request result = %+v", request)
}
assertMemoryNoForwardsAction(t, request.Send.SenderMessage, domain.MessageServiceActionNoForwardsRequest, true, false, false)
answer, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: alice,
PeerUserID: bob,
Enabled: false,
RequestMsgID: request.Send.RecipientMessage.ID,
RandomID: 14,
Date: 103,
})
if err != nil {
t.Fatalf("accept request: %v", err)
}
if answer.State.Enabled() || answer.Send.SenderMessage.Pts != 3 || answer.Send.RecipientMessage.Pts != 3 {
t.Fatalf("answer result = %+v", answer)
}
if answer.Send.SenderMessage.ReplyTo == nil ||
answer.Send.SenderMessage.ReplyTo.MessageID != request.Send.RecipientMessage.ID ||
answer.Send.RecipientMessage.ReplyTo == nil ||
answer.Send.RecipientMessage.ReplyTo.MessageID != request.Send.SenderMessage.ID {
t.Fatalf("answer reply mapping sender=%+v recipient=%+v", answer.Send.SenderMessage.ReplyTo, answer.Send.RecipientMessage.ReplyTo)
}
assertMemoryNoForwardsAction(t, answer.Send.SenderMessage, domain.MessageServiceActionNoForwardsToggle, true, false, false)
aliceHistory, err := messages.ListByUser(ctx, alice, domain.MessageFilter{
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob}, Limit: 20,
})
if err != nil {
t.Fatalf("alice history: %v", err)
}
var expired bool
for _, msg := range aliceHistory.Messages {
if msg.ID == request.Send.RecipientMessage.ID && msg.Media != nil && msg.Media.ServiceAction != nil &&
msg.Media.ServiceAction.NoForwards != nil {
expired = msg.Media.ServiceAction.NoForwards.Expired
}
}
if !expired {
t.Fatal("handled request was not projected expired")
}
if _, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: alice, PeerUserID: bob, RequestMsgID: request.Send.RecipientMessage.ID,
RandomID: 15, Date: 104,
}); !errors.Is(err, domain.ErrNoForwardsRequestExpired) {
t.Fatalf("repeat answer err=%v, want ErrNoForwardsRequestExpired", err)
}
source, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: alice, RecipientUserID: bob, RandomID: 20, Message: "source", Date: 105,
})
if err != nil {
t.Fatalf("send source: %v", err)
}
if _, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: alice, PeerUserID: bob, Enabled: true, RandomID: 21, Date: 106,
}); err != nil {
t.Fatalf("re-enable: %v", err)
}
if _, err := messages.ForwardPrivateMessages(ctx, domain.ForwardPrivateMessagesRequest{
OwnerUserID: alice,
FromPeer: domain.Peer{Type: domain.PeerTypeUser, ID: bob},
ToUserID: alice,
MessageIDs: []int{source.SenderMessage.ID},
RandomIDs: []int64{22},
Date: 107,
}); !errors.Is(err, domain.ErrChatForwardsRestricted) {
t.Fatalf("forward protected chat err=%v, want ErrChatForwardsRestricted", err)
}
}
func TestPrivateNoForwardsRequestExpiresWithoutPTS(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
const alice, bob int64 = 2001, 2002
if _, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: alice, PeerUserID: bob, Enabled: true, RandomID: 31, Date: 200,
}); err != nil {
t.Fatal(err)
}
request, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: bob, PeerUserID: alice, RandomID: 32, Date: 201,
})
if err != nil {
t.Fatal(err)
}
if _, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: alice,
PeerUserID: bob,
RequestMsgID: request.Send.RecipientMessage.ID,
RandomID: 33,
Date: 201 + domain.PrivateNoForwardsRequestExpirePeriod,
}); !errors.Is(err, domain.ErrNoForwardsRequestExpired) {
t.Fatalf("expired answer err=%v", err)
}
state, _ := messages.GetPrivateNoForwards(ctx, alice, bob)
if state.EnabledByUserID != alice {
t.Fatalf("expired answer changed state = %+v", state)
}
history, _ := messages.ListByUser(ctx, alice, domain.MessageFilter{
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob}, Limit: 20,
})
if len(history.Messages) != 2 || history.Messages[0].Pts != 2 {
t.Fatalf("expired answer allocated message/pts: %+v", history.Messages)
}
}
func assertMemoryNoForwardsAction(t *testing.T, msg domain.Message, kind domain.MessageServiceActionKind, prev, next, expired bool) {
t.Helper()
if msg.Media == nil || msg.Media.ServiceAction == nil || msg.Media.ServiceAction.Kind != kind ||
msg.Media.ServiceAction.NoForwards == nil {
t.Fatalf("message action = %+v, want %s", msg.Media, kind)
}
action := msg.Media.ServiceAction.NoForwards
if action.PrevValue != prev || action.NewValue != next || action.Expired != expired {
t.Fatalf("action = %+v, want prev=%v new=%v expired=%v", action, prev, next, expired)
}
}

View file

@ -22,6 +22,9 @@ func (s *MessageStore) SetMessageReactions(_ context.Context, req domain.SetPriv
}
s.mu.Lock()
defer s.mu.Unlock()
if req.Peer.ID == req.UserID {
return s.setSavedMessageTagsLocked(req)
}
var target domain.Message
for _, msg := range s.m[req.UserID] {
if msg.ID == req.MessageID && msg.Peer == req.Peer {
@ -119,6 +122,10 @@ func (s *MessageStore) privateReactionResultLocked(uid int64) domain.PrivateMess
}
func (s *MessageStore) privateMessageReactionsForMessageLocked(msg domain.Message) domain.ChannelMessageReactions {
if msg.OwnerUserID != 0 &&
msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: msg.OwnerUserID}) {
return s.savedMessageTagsForMessageLocked(msg)
}
reactions := s.privateMessageReactionsLocked(msg.UID, msg.OwnerUserID)
if len(reactions.Recent) == 0 || msg.From.ID == 0 {
return reactions
@ -232,6 +239,11 @@ func writeMessageReactionsHash(h hash.Hash64, reactions *domain.ChannelMessageRe
return
}
var buf [16]byte
if reactions.AsTags {
_, _ = h.Write([]byte{1})
} else {
_, _ = h.Write([]byte{0})
}
for _, item := range reactions.Results {
_, _ = h.Write([]byte(item.Reaction.Type))
_, _ = h.Write([]byte{0})

View file

@ -0,0 +1,159 @@
package memory
import (
"context"
"sort"
"telesrv/internal/domain"
)
func (s *MessageStore) setSavedMessageTagsLocked(req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) {
var target domain.Message
for _, msg := range s.m[req.UserID] {
if msg.ID == req.MessageID &&
msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) {
target = msg
break
}
}
if target.ID == 0 {
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
}
for _, reaction := range req.Reactions {
if !reaction.Valid() {
return domain.PrivateMessageReactionsResult{}, domain.ErrReactionInvalid
}
}
if len(req.Reactions) == 0 {
if byMessage := s.savedMessageTags[req.UserID]; byMessage != nil {
delete(byMessage, target.ID)
if len(byMessage) == 0 {
delete(s.savedMessageTags, req.UserID)
}
}
} else {
if s.savedMessageTags[req.UserID] == nil {
s.savedMessageTags[req.UserID] = make(map[int][]domain.MessageReaction)
}
s.savedMessageTags[req.UserID][target.ID] = append([]domain.MessageReaction(nil), req.Reactions...)
}
item := cloneMessage(target)
reactions := s.savedMessageTagsForMessageLocked(item)
item.Reactions = cloneChannelMessageReactionsPtr(&reactions)
return domain.PrivateMessageReactionsResult{
Messages: []domain.Message{item},
Reactions: reactions,
}, nil
}
func (s *MessageStore) savedMessageTagsForMessageLocked(msg domain.Message) domain.ChannelMessageReactions {
out := domain.ChannelMessageReactions{
AsTags: true,
Results: []domain.ChannelMessageReactionCount{},
Recent: []domain.ChannelMessagePeerReaction{},
}
for i, reaction := range s.savedMessageTags[msg.OwnerUserID][msg.ID] {
out.Results = append(out.Results, domain.ChannelMessageReactionCount{
Reaction: reaction,
Count: 1,
ChosenOrder: i + 1,
})
}
return out
}
func (s *MessageStore) ListSavedReactionTags(_ context.Context, req domain.SavedReactionTagsRequest) ([]domain.SavedReactionTag, error) {
if req.UserID == 0 {
return nil, domain.ErrReactionInvalid
}
if req.Limit <= 0 || req.Limit > domain.MaxSavedReactionTags {
req.Limit = domain.MaxSavedReactionTags
}
s.mu.RLock()
defer s.mu.RUnlock()
visible := make(map[int]domain.Message, len(s.m[req.UserID]))
for _, msg := range s.m[req.UserID] {
if msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) {
visible[msg.ID] = msg
}
}
byKey := make(map[string]domain.SavedReactionTag)
for messageID, reactions := range s.savedMessageTags[req.UserID] {
msg, ok := visible[messageID]
if !ok || (req.SavedPeer.ID != 0 && msg.SavedPeer != req.SavedPeer) {
continue
}
for _, reaction := range reactions {
key := reaction.Key()
tag := byKey[key]
tag.UserID = req.UserID
tag.Reaction = reaction
tag.Count++
if req.SavedPeer.ID == 0 {
tag.Title = s.savedTagTitles[req.UserID][key]
}
byKey[key] = tag
}
}
out := make([]domain.SavedReactionTag, 0, len(byKey))
for _, tag := range byKey {
out = append(out, tag)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Reaction.Key() > out[j].Reaction.Key()
})
if len(out) > req.Limit {
out = out[:req.Limit]
}
return out, nil
}
func (s *MessageStore) UpsertSavedReactionTag(_ context.Context, tag domain.SavedReactionTag) error {
if tag.UserID == 0 || !tag.Reaction.Valid() {
return domain.ErrReactionInvalid
}
key := tag.Reaction.Key()
s.mu.Lock()
defer s.mu.Unlock()
found := false
for messageID, reactions := range s.savedMessageTags[tag.UserID] {
alive := false
for _, msg := range s.m[tag.UserID] {
if msg.ID == messageID &&
msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: tag.UserID}) {
alive = true
break
}
}
if !alive {
continue
}
for _, reaction := range reactions {
if reaction.Key() == key {
found = true
break
}
}
if found {
break
}
}
if !found {
return domain.ErrReactionInvalid
}
if tag.Title == "" {
if titles := s.savedTagTitles[tag.UserID]; titles != nil {
delete(titles, key)
}
return nil
}
if s.savedTagTitles[tag.UserID] == nil {
s.savedTagTitles[tag.UserID] = make(map[string]string)
}
s.savedTagTitles[tag.UserID][key] = tag.Title
return nil
}

View file

@ -0,0 +1,150 @@
package memory
import (
"context"
"testing"
"telesrv/internal/domain"
)
func TestSavedMessageTagsAssignmentCountsSearchAndDelete(t *testing.T) {
ctx := context.Background()
const userID int64 = 1001
self := domain.Peer{Type: domain.PeerTypeUser, ID: userID}
peerA := domain.Peer{Type: domain.PeerTypeUser, ID: 2001}
peerB := domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}
thumb := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "👍"}
custom := domain.MessageReaction{Type: domain.MessageReactionCustomEmoji, DocumentID: 90001}
store := NewMessageStore()
create := func(body string, savedPeer domain.Peer) domain.Message {
msg, err := store.Create(ctx, domain.Message{
OwnerUserID: userID,
Peer: self,
From: self,
SavedPeer: savedPeer,
Date: 1_700_000_000,
Body: body,
})
if err != nil {
t.Fatalf("create saved message: %v", err)
}
return msg
}
first := create("first", peerA)
second := create("second", peerA)
third := create("third", peerB)
set := func(msg domain.Message, reactions ...domain.MessageReaction) {
t.Helper()
result, err := store.SetMessageReactions(ctx, domain.SetPrivateMessageReactionsRequest{
UserID: userID,
Peer: self,
MessageID: msg.ID,
Reactions: reactions,
ReactionsPerUserMax: 3,
})
if err != nil {
t.Fatalf("set saved tags for %d: %v", msg.ID, err)
}
if len(result.Messages) != 1 || result.Messages[0].Reactions == nil ||
!result.Messages[0].Reactions.AsTags {
t.Fatalf("saved tag result = %+v, want one reactions_as_tags message", result)
}
}
set(first, thumb)
set(second, thumb, custom)
set(third, custom)
if got := store.nextPts[userID]; got != 0 {
t.Fatalf("tag mutations pts = %d, want 0", got)
}
if err := store.UpsertSavedReactionTag(ctx, domain.SavedReactionTag{
UserID: userID, Reaction: thumb, Title: "Fav",
}); err != nil {
t.Fatalf("rename saved tag: %v", err)
}
global, err := store.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{UserID: userID, Limit: 100})
if err != nil {
t.Fatalf("list global saved tags: %v", err)
}
assertMemorySavedTag(t, global, thumb, 2, "Fav")
assertMemorySavedTag(t, global, custom, 2, "")
perPeer, err := store.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{
UserID: userID, SavedPeer: peerA, Limit: 100,
})
if err != nil {
t.Fatalf("list per-peer saved tags: %v", err)
}
assertMemorySavedTag(t, perPeer, thumb, 2, "")
assertMemorySavedTag(t, perPeer, custom, 1, "")
found, err := store.ListByUser(ctx, userID, domain.MessageFilter{
HasPeer: true,
Peer: self,
SavedPeer: peerA,
SavedReactions: []domain.MessageReaction{custom},
Limit: 10,
})
if err != nil {
t.Fatalf("search saved tag: %v", err)
}
if len(found.Messages) != 1 || found.Messages[0].ID != second.ID ||
found.Messages[0].Reactions == nil || !found.Messages[0].Reactions.AsTags {
t.Fatalf("saved tag search = %+v, want second message", found.Messages)
}
foundAny, err := store.ListByUser(ctx, userID, domain.MessageFilter{
HasPeer: true,
Peer: self,
SavedReactions: []domain.MessageReaction{thumb, custom},
Limit: 10,
})
if err != nil {
t.Fatalf("search any saved tag: %v", err)
}
if len(foundAny.Messages) != 3 {
t.Fatalf("saved tag OR search = %+v, want all three messages", foundAny.Messages)
}
if _, err := store.DeleteMessages(ctx, domain.DeleteMessagesRequest{
OwnerUserID: userID,
IDs: []int{second.ID},
Date: 1_700_000_100,
}); err != nil {
t.Fatalf("delete tagged message: %v", err)
}
global, err = store.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{UserID: userID, Limit: 100})
if err != nil {
t.Fatalf("list tags after delete: %v", err)
}
assertMemorySavedTag(t, global, thumb, 1, "Fav")
assertMemorySavedTag(t, global, custom, 1, "")
set(first)
global, err = store.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{UserID: userID, Limit: 100})
if err != nil {
t.Fatalf("list tags after clear: %v", err)
}
if len(global) != 1 || global[0].Reaction.Key() != custom.Key() {
t.Fatalf("tags after clear = %+v, want only custom", global)
}
if err := store.UpsertSavedReactionTag(ctx, domain.SavedReactionTag{
UserID: userID, Reaction: thumb, Title: "ghost",
}); err != domain.ErrReactionInvalid {
t.Fatalf("rename unassigned tag err = %v, want ErrReactionInvalid", err)
}
}
func assertMemorySavedTag(t *testing.T, tags []domain.SavedReactionTag, reaction domain.MessageReaction, count int, title string) {
t.Helper()
for _, tag := range tags {
if tag.Reaction.Key() == reaction.Key() {
if tag.Count != count || tag.Title != title {
t.Fatalf("tag %s = %+v, want count=%d title=%q", reaction.Key(), tag, count, title)
}
return
}
}
t.Fatalf("tag %s not found in %+v", reaction.Key(), tags)
}

View file

@ -8,12 +8,15 @@ import (
// MessageStore 是 store.MessageStore 的内存实现。
type MessageStore struct {
mu sync.RWMutex
noForwardsMu sync.Mutex
m map[int64][]domain.Message
nextUID int64
nextBox map[int64]int
nextPts map[int64]int
readOutboxDates map[readOutboxDateKey]int
privateReactions map[int64]map[int64][]domain.ChannelMessagePeerReaction
savedMessageTags map[int64]map[int][]domain.MessageReaction
savedTagTitles map[int64]map[string]string
privateSendDedup map[privateSendDedupKey]privateSendDedupRecord
loginCodeDeliveries map[[32]byte]loginCodeDeliveryRecord
albumGroups map[albumGroupKey]albumGroupRecord
@ -22,6 +25,11 @@ type MessageStore struct {
polls *PollStore
// savedPins 是收藏夹子会话置顶顺序(下标即 pinned_order,越小越前)。
savedPins map[int64][]domain.Peer
// privateNoForwards is keyed by the sorted user pair. Requests are keyed by
// the shared logical private-message id so both local box ids resolve to one
// one-shot response fact.
privateNoForwards map[privateNoForwardsPair]domain.PrivateNoForwardsState
privateNoForwardsRequests map[int64]memoryNoForwardsRequest
}
// AttachPollStore 注入共享 poll 权威(与 ChannelStore 共用同一实例)。
@ -38,16 +46,20 @@ type readOutboxDateKey struct {
// NewMessageStore 创建内存 MessageStore。
func NewMessageStore(dialogs ...*DialogStore) *MessageStore {
s := &MessageStore{
m: make(map[int64][]domain.Message),
nextUID: 1,
nextBox: make(map[int64]int),
nextPts: make(map[int64]int),
readOutboxDates: make(map[readOutboxDateKey]int),
privateReactions: make(map[int64]map[int64][]domain.ChannelMessagePeerReaction),
privateSendDedup: make(map[privateSendDedupKey]privateSendDedupRecord),
loginCodeDeliveries: make(map[[32]byte]loginCodeDeliveryRecord),
albumGroups: make(map[albumGroupKey]albumGroupRecord),
savedPins: make(map[int64][]domain.Peer),
m: make(map[int64][]domain.Message),
nextUID: 1,
nextBox: make(map[int64]int),
nextPts: make(map[int64]int),
readOutboxDates: make(map[readOutboxDateKey]int),
privateReactions: make(map[int64]map[int64][]domain.ChannelMessagePeerReaction),
savedMessageTags: make(map[int64]map[int][]domain.MessageReaction),
savedTagTitles: make(map[int64]map[string]string),
privateSendDedup: make(map[privateSendDedupKey]privateSendDedupRecord),
loginCodeDeliveries: make(map[[32]byte]loginCodeDeliveryRecord),
albumGroups: make(map[albumGroupKey]albumGroupRecord),
savedPins: make(map[int64][]domain.Peer),
privateNoForwards: make(map[privateNoForwardsPair]domain.PrivateNoForwardsState),
privateNoForwardsRequests: make(map[int64]memoryNoForwardsRequest),
}
if len(dialogs) > 0 {
s.dialogs = dialogs[0]

View file

@ -1218,29 +1218,191 @@ func TestMessageStoreDeleteHistoryDeletesOrPreservesDialogAndRebuilds(t *testing
preservedOwner := int64(1000000003)
preservedPeerID := int64(1000000004)
preservedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: preservedPeerID}
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: preservedOwner,
RecipientUserID: preservedPeerID,
RandomID: 300,
Message: "clear but keep dialog",
Date: 1700000500,
}); err != nil {
t.Fatalf("seed preserved send: %v", err)
var preservedTop domain.Message
for i := 0; i < 2; i++ {
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: preservedOwner,
RecipientUserID: preservedPeerID,
RandomID: int64(300 + i),
Message: "clear but keep dialog",
Date: 1700000500 + i,
})
if err != nil {
t.Fatalf("seed preserved send %d: %v", i, err)
}
preservedTop = sent.SenderMessage
}
if _, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
clearResult, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: preservedOwner,
Peer: preservedPeer,
JustClear: true,
Date: 1700000600,
}); err != nil {
})
if err != nil {
t.Fatalf("DeleteHistory just_clear: %v", err)
}
clearSelf := clearResult.Self()
if clearSelf.Pts != 5 || clearSelf.PtsCount != 3 || len(clearSelf.MessageIDs) != 1 || len(clearSelf.Events) != 3 {
t.Fatalf("just_clear result = %+v, want delete+read+edit ending pts=5 count=3", clearSelf)
}
if clearSelf.Events[0].Type != domain.UpdateEventDeleteMessages ||
clearSelf.Events[1].Type != domain.UpdateEventReadHistoryInbox ||
clearSelf.Events[2].Type != domain.UpdateEventEditMessage {
t.Fatalf("just_clear events = %+v, want delete/read/edit order", clearSelf.Events)
}
preservedDialogs, err := dialogs.ListByUser(ctx, preservedOwner, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("preserved dialogs: %v", err)
}
if len(preservedDialogs.Dialogs) != 1 || preservedDialogs.Dialogs[0].Peer != preservedPeer || preservedDialogs.Dialogs[0].TopMessage != 0 || len(preservedDialogs.Messages) != 0 {
t.Fatalf("preserved dialogs = %+v messages=%+v, want empty dialog kept after just_clear", preservedDialogs.Dialogs, preservedDialogs.Messages)
if len(preservedDialogs.Dialogs) != 1 || preservedDialogs.Dialogs[0].Peer != preservedPeer ||
preservedDialogs.Dialogs[0].TopMessage != preservedTop.ID || len(preservedDialogs.Messages) != 1 {
t.Fatalf("preserved dialogs = %+v messages=%+v, want history-clear top %d", preservedDialogs.Dialogs, preservedDialogs.Messages, preservedTop.ID)
}
clearMessage := preservedDialogs.Messages[0]
if !domain.IsHistoryClearServiceMessage(clearMessage) || clearMessage.ID != preservedTop.ID ||
!clearMessage.Out || clearMessage.From.ID != preservedOwner || clearMessage.Body != "" ||
clearMessage.ReplyTo != nil || clearMessage.Forward != nil || clearMessage.MediaUnread ||
clearMessage.ReactionUnread || clearMessage.Pinned {
t.Fatalf("history clear anchor = %+v, want clean owner-local service message", clearMessage)
}
repeated, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: preservedOwner,
Peer: preservedPeer,
JustClear: true,
Date: 1700000601,
})
if err != nil {
t.Fatalf("repeat DeleteHistory just_clear: %v", err)
}
if repeated.Changed() || len(repeated.Deleted) != 0 || messages.nextPts[preservedOwner] != 5 {
t.Fatalf("repeat just_clear = %+v pts=%d, want idempotent no-op", repeated, messages.nextPts[preservedOwner])
}
}
func TestMessageStoreDeleteHistoryJustClearRevokeKeepsPerOwnerAnchors(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
const alice, bob = int64(1101), int64(1102)
var sent domain.SendPrivateTextResult
for i := 0; i < 2; i++ {
var err error
sent, err = messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: alice, RecipientUserID: bob, RandomID: int64(800 + i),
Message: "revoke clear", Date: 1700000700 + i,
})
if err != nil {
t.Fatalf("send %d: %v", i, err)
}
}
res, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: alice,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob},
JustClear: true,
Revoke: true,
Date: 1700000800,
})
if err != nil {
t.Fatalf("revoke just_clear: %v", err)
}
if len(res.Deleted) != 2 {
t.Fatalf("deleted owners = %+v, want alice and bob", res.Deleted)
}
for _, tc := range []struct {
userID int64
peerID int64
topID int
}{
{alice, bob, sent.SenderMessage.ID},
{bob, alice, sent.RecipientMessage.ID},
} {
history, err := messages.ListByUser(ctx, tc.userID, domain.MessageFilter{
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: tc.peerID}, Limit: 10,
})
if err != nil {
t.Fatalf("history user %d: %v", tc.userID, err)
}
if len(history.Messages) != 1 || history.Messages[0].ID != tc.topID ||
!domain.IsHistoryClearServiceMessage(history.Messages[0]) ||
history.Messages[0].From.ID != tc.userID || !history.Messages[0].Out {
t.Fatalf("history user %d = %+v, want owner-local anchor %d", tc.userID, history.Messages, tc.topID)
}
}
}
func TestMessageStoreDeleteHistoryDateRangeDoesNotCreateHistoryClearAnchor(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
const owner, peerID = int64(1201), int64(1202)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: peerID}
for i, date := range []int{100, 200} {
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: owner, RecipientUserID: peerID, RandomID: int64(900 + i),
Message: "dated", Date: date,
}); err != nil {
t.Fatalf("send %d: %v", i, err)
}
}
if _, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner, Peer: peer, JustClear: true, MinDate: 150, MaxDate: 250, Date: 300,
}); err != nil {
t.Fatalf("date delete: %v", err)
}
history, err := messages.ListByUser(ctx, owner, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10})
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history.Messages) != 1 || history.Messages[0].Date != 100 || domain.IsHistoryClearServiceMessage(history.Messages[0]) {
t.Fatalf("date history = %+v, want surviving ordinary message only", history.Messages)
}
}
func TestMessageStoreDeleteHistoryJustClearKeepsAnchorAcrossBatches(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
const owner, peerID = int64(1301), int64(1302)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: peerID}
total := domain.MaxDeleteHistoryBatch + 2
var topID int
for i := 0; i < total; i++ {
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: owner, RecipientUserID: peerID, RandomID: int64(10000 + i),
Message: "batch clear", Date: 1700010000 + i,
})
if err != nil {
t.Fatalf("send %d: %v", i, err)
}
topID = sent.SenderMessage.ID
}
first, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner, Peer: peer, JustClear: true, Date: 1700020000,
})
if err != nil {
t.Fatalf("first clear: %v", err)
}
if first.Offset == 0 || len(first.Self().MessageIDs) != domain.MaxDeleteHistoryBatch ||
first.Self().PtsCount != domain.MaxDeleteHistoryBatch+2 {
t.Fatalf("first clear = %+v, want full batch plus one read/edit", first.Self())
}
second, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner, Peer: peer, JustClear: true, Date: 1700020001,
})
if err != nil {
t.Fatalf("second clear: %v", err)
}
if second.Offset != 0 || len(second.Self().MessageIDs) != 1 || second.Self().PtsCount != 1 ||
len(second.Self().Events) != 1 || second.Self().Events[0].Type != domain.UpdateEventDeleteMessages {
t.Fatalf("second clear = %+v, want remaining delete only", second.Self())
}
history, err := messages.ListByUser(ctx, owner, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10})
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history.Messages) != 1 || history.Messages[0].ID != topID ||
!domain.IsHistoryClearServiceMessage(history.Messages[0]) {
t.Fatalf("history = %+v, want stable top anchor %d", history.Messages, topID)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,221 @@
package memory
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
func TestModerationCaseLifecycleAndNewReportsDuringAction(t *testing.T) {
ctx := context.Background()
now := time.Now().UTC()
target := domain.Peer{Type: domain.PeerTypeUser, ID: 900}
store := NewModerationReportStore()
create := func(reporter int64, option string, at time.Time) domain.ModerationReport {
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: reporter, Source: domain.ModerationSourceAccountPeer,
Target: target, Reason: domain.ModerationReasonFake,
Option: option,
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"schema_version":1}`),
}},
CreatedAt: at,
})
if err != nil {
t.Fatal(err)
}
stored, created, err := store.CreateModerationReport(ctx, report)
if err != nil || !created {
t.Fatalf("create report created=%v err=%v", created, err)
}
return stored
}
create(101, "fake", now)
create(102, "fake:impersonation", now.Add(time.Second))
cases, err := store.ListModerationCases(ctx, domain.ModerationCaseFilter{Limit: 10})
if err != nil || len(cases) != 1 {
t.Fatalf("cases=%+v err=%v", cases, err)
}
item := cases[0]
if item.ReportCount != 2 || item.DistinctReporterCount != 2 ||
item.Version != 2 || item.Severity != domain.ModerationSeverityMedium {
t.Fatalf("case aggregate=%+v", item)
}
claimed, err := store.ClaimModerationCase(ctx, item.ID, item.Version, "reviewer", now.Add(2*time.Second))
if err != nil || claimed.Status != domain.ModerationCaseInReview {
t.Fatalf("claim=%+v err=%v", claimed, err)
}
decision, err := domain.NewModerationDecisionRequest(domain.ModerationDecisionRequest{
CaseID: item.ID, ExpectedVersion: claimed.Version, Actor: "reviewer",
Reason: "confirmed impersonation", CommandID: "decision-1",
Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
}},
CreatedAt: now.Add(3 * time.Second),
})
if err != nil {
t.Fatal(err)
}
detail, created, err := store.DecideModerationCase(ctx, decision)
if err != nil || !created ||
detail.Case.Status != domain.ModerationCaseActionPending ||
len(detail.Actions) != 1 {
t.Fatalf("decision detail=%+v created=%v err=%v", detail, created, err)
}
if _, created, err := store.DecideModerationCase(ctx, decision); err != nil || created {
t.Fatalf("decision retry created=%v err=%v", created, err)
}
// Once a decision is durable, later reports open a new case instead of
// mutating the evidence set under the pending action.
create(103, "fake:new-evidence", now.Add(4*time.Second))
cases, err = store.ListModerationCases(ctx, domain.ModerationCaseFilter{Limit: 10})
if err != nil || len(cases) != 2 {
t.Fatalf("cases after new evidence=%+v err=%v", cases, err)
}
actions, err := store.ClaimModerationActions(ctx, now.Add(5*time.Second), 10, time.Minute)
if err != nil || len(actions) != 1 {
t.Fatalf("claimed actions=%+v err=%v", actions, err)
}
if err := store.CompleteModerationAction(
ctx, actions[0].ID, actions[0].Attempts, true, "",
time.Time{}, now.Add(6*time.Second),
); err != nil {
t.Fatal(err)
}
resolved, found, err := store.GetModerationCase(ctx, item.ID)
if err != nil || !found || resolved.Case.Status != domain.ModerationCaseResolved {
t.Fatalf("resolved=%+v found=%v err=%v", resolved, found, err)
}
appeal, err := domain.NewModerationAppeal(
item.ID, target.ID, domain.ModerationCaseResolved,
"This is a mistake.", now.Add(7*time.Second),
)
if err != nil {
t.Fatal(err)
}
if _, created, err := store.CreateModerationAppeal(ctx, appeal); err != nil || !created {
t.Fatalf("appeal created=%v err=%v", created, err)
}
appealed, _, _ := store.GetModerationCase(ctx, item.ID)
if appealed.Case.Status != domain.ModerationCaseAppealReview ||
len(appealed.Appeals) != 1 {
t.Fatalf("appealed detail=%+v", appealed)
}
}
func TestModerationActionFailedCanBeRedrivenByNewDecision(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_750_000_000, 0).UTC()
target := domain.Peer{Type: domain.PeerTypeUser, ID: 902}
store := NewModerationReportStore()
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: 901, Source: domain.ModerationSourceAccountPeer,
Target: target, Reason: domain.ModerationReasonSpam, Option: "spam",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"schema_version":1}`),
}},
CreatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if _, created, err := store.CreateModerationReport(ctx, report); err != nil || !created {
t.Fatalf("create report created=%v err=%v", created, err)
}
cases, err := store.ListModerationCases(ctx, domain.ModerationCaseFilter{Limit: 10})
if err != nil || len(cases) != 1 {
t.Fatalf("cases=%+v err=%v", cases, err)
}
claimed, err := store.ClaimModerationCase(
ctx, cases[0].ID, cases[0].Version, "reviewer", now.Add(time.Second),
)
if err != nil {
t.Fatal(err)
}
firstDecision, err := domain.NewModerationDecisionRequest(domain.ModerationDecisionRequest{
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
Actor: "reviewer", Reason: "first command kept failing",
CommandID: "redrive-first", Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionMarkScam, Payload: []byte(`{}`),
}},
CreatedAt: now.Add(2 * time.Second),
})
if err != nil {
t.Fatal(err)
}
if _, created, err := store.DecideModerationCase(ctx, firstDecision); err != nil || !created {
t.Fatalf("first decision created=%v err=%v", created, err)
}
for attempt := 1; attempt <= domain.MaxModerationActionAttempts; attempt++ {
at := now.Add(time.Duration(attempt+2) * time.Second)
actions, err := store.ClaimModerationActions(ctx, at, 10, time.Second)
if err != nil || len(actions) != 1 {
t.Fatalf("attempt %d actions=%+v err=%v", attempt, actions, err)
}
if err := store.CompleteModerationAction(
ctx, actions[0].ID, actions[0].Attempts, false, "downstream unavailable",
at.Add(time.Millisecond), at,
); err != nil {
t.Fatalf("attempt %d: %v", attempt, err)
}
}
failed, found, err := store.GetModerationCase(ctx, claimed.ID)
if err != nil || !found || failed.Case.Status != domain.ModerationCaseActionFailed ||
len(failed.Actions) != 1 ||
failed.Actions[0].Status != domain.ModerationActionFailed {
t.Fatalf("failed=%+v found=%v err=%v", failed, found, err)
}
redrive, err := domain.NewModerationDecisionRequest(domain.ModerationDecisionRequest{
CaseID: claimed.ID, ExpectedVersion: failed.Case.Version,
Actor: "reviewer", Reason: "redrive after dependency recovery",
CommandID: "redrive-second", Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionMarkScam, Payload: []byte(`{}`),
}},
CreatedAt: now.Add(time.Minute),
})
if err != nil {
t.Fatal(err)
}
pending, created, err := store.DecideModerationCase(ctx, redrive)
if err != nil || !created ||
pending.Case.Status != domain.ModerationCaseActionPending ||
len(pending.Actions) != 2 {
t.Fatalf("pending=%+v created=%v err=%v", pending, created, err)
}
actions, err := store.ClaimModerationActions(ctx, now.Add(2*time.Minute), 10, time.Second)
if err != nil || len(actions) != 1 || actions[0].DecisionID == failed.Actions[0].DecisionID {
t.Fatalf("redrive actions=%+v err=%v", actions, err)
}
if err := store.CompleteModerationAction(
ctx, actions[0].ID, actions[0].Attempts, true, "",
time.Time{}, now.Add(2*time.Minute+time.Second),
); err != nil {
t.Fatal(err)
}
resolved, found, err := store.GetModerationCase(ctx, claimed.ID)
if err != nil || !found || resolved.Case.Status != domain.ModerationCaseResolved {
t.Fatalf("resolved=%+v found=%v err=%v", resolved, found, err)
}
var failedCount, succeededCount int
for _, action := range resolved.Actions {
switch action.Status {
case domain.ModerationActionFailed:
failedCount++
case domain.ModerationActionSucceeded:
succeededCount++
}
}
if failedCount != 1 || succeededCount != 1 {
t.Fatalf("action history failed=%d succeeded=%d", failedCount, succeededCount)
}
}

View file

@ -0,0 +1,92 @@
package memory
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
func TestModerationReportStoreIdempotencyAndCopyIsolation(t *testing.T) {
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: 11, Source: domain.ModerationSourceMessages,
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 22},
Reason: domain.ModerationReasonSpam, Option: "v1/spam",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemMessage,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 22},
ItemID: 5, AuthorUserID: 22, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"message":"spam"}`),
}},
CreatedAt: time.Now().UTC(),
})
if err != nil {
t.Fatal(err)
}
store := NewModerationReportStore()
first, created, err := store.CreateModerationReport(context.Background(), report)
if err != nil || !created || first.ID <= 0 {
t.Fatalf("first = %+v created=%v err=%v", first, created, err)
}
first.Items[0].Evidence[0] = '['
retry, created, err := store.CreateModerationReport(context.Background(), report)
if err != nil || created || retry.ID != first.ID {
t.Fatalf("retry = %+v created=%v err=%v", retry, created, err)
}
if retry.Items[0].Evidence[0] != '{' {
t.Fatalf("caller mutation changed stored evidence: %s", retry.Items[0].Evidence)
}
}
func TestModerationReportStoreRateLimitDoesNotChargeIdempotentRetry(t *testing.T) {
ctx := context.Background()
store := NewModerationReportStore()
now := time.Unix(1_750_000_000, 0).UTC()
var first domain.ModerationReport
for i := 0; i < domain.MaxModerationReportsPerHour; i++ {
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: 71, Source: domain.ModerationSourceMessages,
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
Reason: domain.ModerationReasonSpam, Option: "spam",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemMessage,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
ItemID: int64(i + 1), AuthorUserID: 72,
EvidenceSchemaVersion: 1,
Evidence: []byte(`{"message":"spam"}`),
}},
CreatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if _, created, err := store.CreateModerationReport(ctx, report); err != nil || !created {
t.Fatalf("create %d: created=%v err=%v", i, created, err)
}
if i == 0 {
first = report
}
}
if got, created, err := store.CreateModerationReport(ctx, first); err != nil || created || got.ID == 0 {
t.Fatalf("retry after limit: got=%+v created=%v err=%v", got, created, err)
}
overflow, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: 71, Source: domain.ModerationSourceMessages,
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
Reason: domain.ModerationReasonSpam, Option: "spam",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemMessage,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
ItemID: 999, AuthorUserID: 72, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"message":"overflow"}`),
}},
CreatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateModerationReport(ctx, overflow); err != domain.ErrModerationRateLimited {
t.Fatalf("overflow err=%v, want ErrModerationRateLimited", err)
}
}

View file

@ -160,6 +160,18 @@ func (s *PasswordStore) GetAccountSettings(_ context.Context, userID int64) (dom
return settings, ok, nil // AccountSettings 全是值类型,无需深拷贝
}
func (s *PasswordStore) GetAccountSettingsBatch(_ context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error) {
out := make(map[int64]domain.AccountSettings, len(userIDs))
s.mu.RLock()
for _, userID := range userIDs {
if settings, ok := s.accountSettings[userID]; ok {
out[userID] = settings
}
}
s.mu.RUnlock()
return out, nil
}
func (s *PasswordStore) SaveAccountSettings(_ context.Context, userID int64, settings domain.AccountSettings) error {
s.mu.Lock()
s.accountSettings[userID] = settings

View file

@ -243,7 +243,7 @@ func (s *MessageStore) DeleteSavedHistory(_ context.Context, req domain.DeleteSa
return true
}
deleted, _, more := s.deleteMemoryMessagesLocked(req.OwnerUserID, domain.MaxDeleteHistoryBatch, match)
delRes := s.finishMemoryDeleteLocked(domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID}, deleted, req.Date, false)
delRes := s.finishMemoryDeleteLocked(domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID}, deleted, req.Date, nil)
res.More = more
for _, d := range delRes.Deleted {
if d.UserID == req.OwnerUserID {

View file

@ -2,6 +2,7 @@ package memory
import (
"context"
"math/rand/v2"
"sort"
"strings"
"sync"
@ -253,6 +254,20 @@ func (s *StarGiftStore) ActiveCollectibleRevision(_ context.Context, giftID int6
return cloneCollectibleRevision(revision), ok, nil
}
func (s *StarGiftStore) ActiveCollectibleProjection(_ context.Context, giftID int64, samplePerKind int) (domain.StarGiftCollectibleRevision, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
revision, ok := s.collectibles[giftID]
if !ok {
return domain.StarGiftCollectibleRevision{}, false, nil
}
projection := cloneCollectibleRevision(revision)
projection.Models = projectCollectibleAttributes(projection.Models, domain.StarGiftCollectibleModel, samplePerKind)
projection.Patterns = projectCollectibleAttributes(projection.Patterns, domain.StarGiftCollectiblePattern, samplePerKind)
projection.Backdrops = projectCollectibleAttributes(projection.Backdrops, domain.StarGiftCollectibleBackdrop, samplePerKind)
return projection, true, nil
}
func (s *StarGiftStore) CollectibleAvailability(_ context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
s.mu.Lock()
defer s.mu.Unlock()
@ -506,6 +521,10 @@ func (s *StarGiftStore) GetByRef(_ context.Context, ref domain.SavedStarGiftRef)
return domain.SavedStarGift{}, false, nil
}
func (s *StarGiftStore) ResolveUserMessageRef(_ context.Context, _ int64, _ int) (domain.SavedStarGiftRef, bool, error) {
return domain.SavedStarGiftRef{}, false, nil
}
func (s *StarGiftStore) CountByOwner(_ context.Context, owner domain.Peer) (int, error) {
if !validStarGiftOwner(owner) {
return 0, nil
@ -845,6 +864,34 @@ func cloneCollectibleRevision(in domain.StarGiftCollectibleRevision) domain.Star
return out
}
func projectCollectibleAttributes(in []domain.StarGiftCollectibleAttribute, kind domain.StarGiftCollectibleAttributeKind, samplePerKind int) []domain.StarGiftCollectibleAttribute {
out := in
if samplePerKind > 0 {
out = make([]domain.StarGiftCollectibleAttribute, 0, len(in))
for _, attribute := range in {
if attribute.RarityKind != domain.StarGiftRarityPermille || attribute.RarityPermille <= 0 ||
(kind == domain.StarGiftCollectibleModel && attribute.Crafted) {
continue
}
out = append(out, attribute)
}
for i := 0; i < len(out) && i < samplePerKind; i++ {
j := i + rand.IntN(len(out)-i)
out[i], out[j] = out[j], out[i]
}
if len(out) > samplePerKind {
out = out[:samplePerKind]
}
}
for i := range out {
if out[i].Animation != nil {
out[i].Animation.JSON = nil
out[i].Animation.TGS = nil
}
}
return out
}
func cloneStarGiftCollections(in []domain.StarGiftCollection) []domain.StarGiftCollection {
out := make([]domain.StarGiftCollection, len(in))
for i, collection := range in {

View file

@ -93,12 +93,13 @@ func (s *StarsStore) Debit(_ context.Context, userID, amount int64, reason domai
return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: st.granted}, nil
}
func (s *StarsStore) ListTransactions(_ context.Context, userID int64, offset string, limit int) (domain.StarsTransactionPage, error) {
func (s *StarsStore) ListTransactions(_ context.Context, userID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
if userID == 0 {
return domain.StarsTransactionPage{}, nil
}
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
limit = domain.MaxStarsTransactionsLimit
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.StarsTransactionPage{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
@ -107,22 +108,35 @@ func (s *StarsStore) ListTransactions(_ context.Context, userID int64, offset st
return domain.StarsTransactionPage{}, nil
}
page := domain.StarsTransactionPage{Balance: st.balance}
cursor, hasCursor := domain.DecodeStarsCursor(offset)
// 倒序遍历(id DESC)。
out := make([]domain.StarsTransaction, 0, limit)
for i := len(st.txns) - 1; i >= 0; i-- {
t := st.txns[i]
if hasCursor && t.ID >= cursor {
continue
cursor, hasCursor := domain.DecodeStarsCursor(query.Offset)
out := make([]domain.StarsTransaction, 0, query.Limit+1)
appendMatch := func(t domain.StarsTransaction) bool {
if hasCursor {
if query.Ascending && t.ID <= cursor {
return false
}
if !query.Ascending && t.ID >= cursor {
return false
}
}
if !query.Direction.IncludesAmount(t.Amount) {
return false
}
out = append(out, t)
if len(out) == limit {
// 还有更早的流水则给出下一页游标。
if i-1 >= 0 {
page.NextOffset = domain.EncodeStarsCursor(t.ID)
}
break
return len(out) > query.Limit
}
if query.Ascending {
for i := 0; i < len(st.txns) && len(out) <= query.Limit; i++ {
appendMatch(st.txns[i])
}
} else {
for i := len(st.txns) - 1; i >= 0 && len(out) <= query.Limit; i-- {
appendMatch(st.txns[i])
}
}
if len(out) > query.Limit {
out = out[:query.Limit]
page.NextOffset = domain.EncodeStarsCursor(out[len(out)-1].ID)
}
page.Transactions = out
return page, nil

View file

@ -1597,7 +1597,6 @@ func storyViewerMatchesQuery(viewerID int64, query string, profile domain.User,
profile.LastName,
strings.TrimSpace(profile.FirstName + " " + profile.LastName),
profile.Username,
profile.Phone,
strconv.FormatInt(viewerID, 10),
}
if isContact {
@ -1610,7 +1609,6 @@ func storyViewerMatchesQuery(viewerID int64, query string, profile domain.User,
contact.User.LastName,
strings.TrimSpace(contact.User.FirstName+" "+contact.User.LastName),
contact.User.Username,
contact.User.Phone,
)
}
for _, candidate := range candidates {

View file

@ -1627,6 +1627,33 @@ func TestStoryStoreListStoryViewsFiltersByContactsAndQuery(t *testing.T) {
t.Fatalf("username query = %+v, want viewer 2002", stranger)
}
hiddenAccountPhone, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{
ViewerUserID: owner.ID,
Owner: owner,
StoryID: 1,
Limit: 10,
Query: "155502",
})
if err != nil {
t.Fatalf("list query hidden account phone: %v", err)
}
if hiddenAccountPhone.Count != 0 || len(hiddenAccountPhone.Views) != 0 {
t.Fatalf("hidden account phone query = %+v, want no match", hiddenAccountPhone)
}
knownContactPhone, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{
ViewerUserID: owner.ID,
Owner: owner,
StoryID: 1,
Limit: 10,
Query: "7001",
})
if err != nil {
t.Fatalf("list query known contact phone: %v", err)
}
if knownContactPhone.Count != 1 || len(knownContactPhone.Views) != 1 || knownContactPhone.Views[0].ViewerID != 2001 {
t.Fatalf("known contact phone query = %+v, want viewer 2001", knownContactPhone)
}
intersection, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{
ViewerUserID: owner.ID,
Owner: owner,

View file

@ -12,9 +12,10 @@ import (
// UserStore 是 store.UserStore 的内存实现。ID 与 PG identity 使用同一业务起点。
type UserStore struct {
mu sync.RWMutex
byID map[int64]domain.User
nextID int64
mu sync.RWMutex
byID map[int64]domain.User
nextID int64
usernameRegistry *CollectibleUsernameStore
}
// NewUserStore 创建内存 UserStore。内置系统账号(777000 / BotFather / Stickers / ChatBot)
@ -29,6 +30,14 @@ func NewUserStore() *UserStore {
return s
}
// AttachUsernameRegistry gives the memory backend the same global username
// index the PostgreSQL stores share through peer_usernames.
func (s *UserStore) AttachUsernameRegistry(registry *CollectibleUsernameStore) {
s.mu.Lock()
s.usernameRegistry = registry
s.mu.Unlock()
}
func (s *UserStore) ByID(_ context.Context, id int64) (domain.User, bool, error) {
s.mu.RLock()
u, ok := s.byID[id]
@ -122,18 +131,25 @@ func (s *UserStore) ByPhones(_ context.Context, phones []string) ([]domain.User,
return out, nil
}
func (s *UserStore) ByUsername(_ context.Context, username string) (domain.User, bool, error) {
func (s *UserStore) ByUsername(ctx context.Context, username string) (domain.User, bool, error) {
username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
if username == "" {
return domain.User{}, false, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
registry := s.usernameRegistry
for _, u := range s.byID {
if !u.Deleted && strings.ToLower(u.Username) == username {
s.mu.RUnlock()
return u, true, nil
}
}
s.mu.RUnlock()
if registry != nil {
if peer, ok := registry.activeUsernamePeer(username, domain.PeerTypeUser); ok {
return s.ByID(ctx, peer.ID)
}
}
return domain.User{}, false, nil
}
@ -162,13 +178,21 @@ func (s *UserStore) Search(_ context.Context, currentUserID int64, query, phoneQ
return domain.UserSearchResult{}, nil
}
s.mu.RLock()
registry := s.usernameRegistry
s.mu.RUnlock()
var usernameMatches map[int64]int
if registry != nil {
usernameMatches = registry.activeUsernameMatches(query, domain.PeerTypeUser)
}
s.mu.RLock()
defer s.mu.RUnlock()
users := make([]domain.User, 0)
for _, u := range s.byID {
if u.ID == currentUserID || u.Deleted {
continue
}
if userMatchesSearch(u, query, phoneQuery) {
_, usernameMatch := usernameMatches[u.ID]
if usernameMatch || userMatchesSearch(u, query, phoneQuery) {
users = append(users, u)
}
}
@ -181,7 +205,7 @@ func (s *UserStore) Search(_ context.Context, currentUserID int64, query, phoneQ
return domain.UserSearchResult{Results: users}, nil
}
func (s *UserStore) UpdateUsername(_ context.Context, userID int64, username string) (domain.User, error) {
func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) {
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
usernameLower := strings.ToLower(username)
s.mu.Lock()
@ -197,6 +221,11 @@ func (s *UserStore) UpdateUsername(_ context.Context, userID int64, username str
}
}
}
if s.usernameRegistry != nil {
if _, err := s.usernameRegistry.SetEditableUsername(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: userID}, username); err != nil {
return domain.User{}, err
}
}
u.Username = username
s.byID[userID] = u
return u, nil

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,760 @@
package memory
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"telesrv/internal/domain"
)
// verificationTestDraft is a payload that clears domain.ValidateForSubmission:
// a category, a long enough description, a website and two independent press
// links.
func verificationTestDraft() domain.VerificationDraftInput {
return domain.VerificationDraftInput{
Category: "media",
Description: strings.Repeat("independent newsroom covering the region ", 2),
OfficialWebsite: "https://example.com",
SocialLinks: []string{"https://t.me/example"},
PressLinks: []string{
"https://press.example.com/story",
"https://press.example.org/profile",
},
AdditionalNote: "filed through the bot dialog",
}
}
func verificationTestRequest(applicant int64, targetType domain.VerificationTargetType, targetID int64, username string) domain.SubmitVerificationApplicationRequest {
return domain.SubmitVerificationApplicationRequest{
ApplicantUserID: applicant,
TargetType: targetType,
TargetID: targetID,
TargetTitle: "Target " + username,
TargetUsername: username,
Draft: verificationTestDraft(),
CorrelationID: fmt.Sprintf("corr-%d", targetID),
}
}
// submittedVerificationApplication drives the applicant path up to the review
// queue, which is the state every reviewer test starts from.
func submittedVerificationApplication(t *testing.T, s *VerificationStore, applicant int64, targetType domain.VerificationTargetType, targetID int64, username string) domain.VerificationApplication {
t.Helper()
ctx := context.Background()
app, created, err := s.CreateVerificationDraft(ctx, verificationTestRequest(applicant, targetType, targetID, username))
if err != nil || !created {
t.Fatalf("create draft: created=%v err=%v", created, err)
}
app, err = s.SubmitVerificationApplication(ctx, app.ID, app.Version)
if err != nil {
t.Fatalf("submit: %v", err)
}
return app
}
// TestMemoryVerificationDraftLifecycle covers the applicant path: a draft is
// opened once and resumed on the next /start, the payload is stored verbatim, and
// submission stamps the queue entry.
func TestMemoryVerificationDraftLifecycle(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
req := verificationTestRequest(1001, domain.VerificationTargetBot, 5001, "AlphaBot")
req.Draft = domain.VerificationDraftInput{Category: "media"}
app, created, err := s.CreateVerificationDraft(ctx, req)
if err != nil || !created {
t.Fatalf("create draft: created=%v err=%v", created, err)
}
if app.Status != domain.VerificationStatusDraft || app.Version != 1 {
t.Fatalf("draft state = %s v%d, want draft v1", app.Status, app.Version)
}
if app.TargetUsername != "AlphaBot" || app.CorrelationID != "corr-5001" {
t.Fatalf("draft snapshot = %q / %q", app.TargetUsername, app.CorrelationID)
}
if !app.SubmittedAt.IsZero() || !app.ReviewedAt.IsZero() || app.ReviewerAdminID != "" {
t.Fatal("fresh draft carries review metadata")
}
// The bot dialog is one conversation: a second /start resumes the draft.
resumed, created, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1001, domain.VerificationTargetBot, 5001, "AlphaBot"))
if err != nil || created {
t.Fatalf("resume draft: created=%v err=%v", created, err)
}
if resumed.ID != app.ID || resumed.Version != app.Version {
t.Fatalf("resumed draft = %d v%d, want %d v%d", resumed.ID, resumed.Version, app.ID, app.Version)
}
if _, err := s.SubmitVerificationApplication(ctx, app.ID, app.Version); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("submit incomplete draft err = %v, want ErrVerificationApplicationInvalid", err)
}
saved, err := s.SaveVerificationDraft(ctx, app.ID, app.Version, verificationTestDraft())
if err != nil {
t.Fatalf("save draft: %v", err)
}
if saved.Version != app.Version+1 || len(saved.PressLinks) != 2 || saved.OfficialWebsite != "https://example.com" {
t.Fatalf("saved draft = v%d links=%v site=%q", saved.Version, saved.PressLinks, saved.OfficialWebsite)
}
if _, err := s.SaveVerificationDraft(ctx, app.ID, app.Version, verificationTestDraft()); !errors.Is(err, domain.ErrVerificationVersionConflict) {
t.Fatalf("stale save err = %v, want ErrVerificationVersionConflict", err)
}
if _, err := s.SaveVerificationDraft(ctx, app.ID, saved.Version, domain.VerificationDraftInput{OfficialWebsite: "http://127.0.0.1/x"}); !errors.Is(err, domain.ErrVerificationURLInvalid) {
t.Fatalf("private-host save err = %v, want ErrVerificationURLInvalid", err)
}
submitted, err := s.SubmitVerificationApplication(ctx, saved.ID, saved.Version)
if err != nil {
t.Fatalf("submit: %v", err)
}
if submitted.Status != domain.VerificationStatusSubmitted || submitted.SubmittedAt.IsZero() {
t.Fatalf("submitted = %s at %v", submitted.Status, submitted.SubmittedAt)
}
if !submitted.ReviewedAt.IsZero() || submitted.ReviewerAdminID != "" {
t.Fatal("submitted application carries a reviewer")
}
if _, err := s.VerificationDraftForApplicant(ctx, 1001); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("draft after submit err = %v, want ErrVerificationApplicationNotFound", err)
}
active, err := s.ActiveVerificationApplicationForTarget(ctx, domain.VerificationTargetBot, 5001)
if err != nil || active.ID != app.ID {
t.Fatalf("active for target = %d err=%v", active.ID, err)
}
// Returned slices are copies: mutating them must not reach the store.
submitted.PressLinks[0] = "https://evil.example.com"
reread, err := s.VerificationApplication(ctx, app.ID)
if err != nil || reread.PressLinks[0] != "https://press.example.com/story" {
t.Fatalf("stored press links mutated through the caller: %v err=%v", reread.PressLinks, err)
}
}
// TestMemoryVerificationActiveTargetUniqueness is the partial unique index: one
// live application per target, and a decided one no longer blocks a fresh
// attempt.
func TestMemoryVerificationActiveTargetUniqueness(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
first := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 6001, "beta")
if _, _, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1002, domain.VerificationTargetChannel, 6001, "beta")); !errors.Is(err, domain.ErrVerificationApplicationExists) {
t.Fatalf("second application err = %v, want ErrVerificationApplicationExists", err)
}
// A different target is free for the same target id in another namespace.
namespaced, _, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1002, domain.VerificationTargetBot, 6001, "betabot"))
if err != nil {
t.Fatalf("other namespace draft: %v", err)
}
// One draft per applicant: naming another target resumes the same
// conversation instead of opening a second draft.
resumed, created, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1002, domain.VerificationTargetBot, 6002, "betabot2"))
if err != nil || created || resumed.ID != namespaced.ID {
t.Fatalf("cross-target draft = %d created=%v err=%v, want draft %d", resumed.ID, created, err, namespaced.ID)
}
cancelled, err := s.CancelVerificationApplication(ctx, first.ID, first.Version, "changed my mind")
if err != nil {
t.Fatalf("cancel: %v", err)
}
if cancelled.Status != domain.VerificationStatusCancelled || cancelled.SubmittedAt.IsZero() {
t.Fatalf("cancelled = %s at %v", cancelled.Status, cancelled.SubmittedAt)
}
if cancelled.DecisionReason != "" {
t.Fatalf("cancel wrote the applicant reason into decision_reason: %q", cancelled.DecisionReason)
}
if _, err := s.CancelVerificationApplication(ctx, cancelled.ID, cancelled.Version, "again"); !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("cancel of cancelled err = %v, want ErrVerificationStatusInvalid", err)
}
if _, _, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1003, domain.VerificationTargetChannel, 6001, "beta")); err != nil {
t.Fatalf("draft after cancellation: %v", err)
}
}
// TestMemoryVerificationClaimAndApprove is the reviewer path, including the
// invariant that matters most: the peer flag is written by the callback and the
// application is only approved if that callback succeeded.
func TestMemoryVerificationClaimAndApprove(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 7001, "gamma")
claimed, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
})
if err != nil {
t.Fatalf("claim: %v", err)
}
if claimed.Status != domain.VerificationStatusInReview || claimed.ReviewerAdminID != "admin-a" {
t.Fatalf("claimed = %s by %q", claimed.Status, claimed.ReviewerAdminID)
}
if !claimed.ReviewedAt.IsZero() {
t.Fatal("claim stamped reviewed_at, which the schema pairs with a decision")
}
if _, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-b",
}); !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("re-claim err = %v, want ErrVerificationStatusInvalid", err)
}
// A callback failure must roll the whole decision back.
failing := errors.New("peer store unavailable")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a",
}, true, func(context.Context, domain.VerificationApplication) error {
return failing
}); !errors.Is(err, failing) {
t.Fatalf("failing approve err = %v, want %v", err, failing)
}
rolled, err := s.VerificationApplication(ctx, app.ID)
if err != nil {
t.Fatalf("read after failed approve: %v", err)
}
if rolled.Status != domain.VerificationStatusInReview || rolled.Version != claimed.Version {
t.Fatalf("failed approve left %s v%d, want in_review v%d", rolled.Status, rolled.Version, claimed.Version)
}
if !rolled.ReviewedAt.IsZero() || rolled.DecisionReason != "" {
t.Fatal("failed approve wrote decision metadata")
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 0 {
t.Fatalf("outbox after failed approve = %d rows err=%v", len(pending), err)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 10)
if err != nil {
t.Fatalf("events: %v", err)
}
for _, event := range events {
if event.Kind == domain.VerificationEventApproved {
t.Fatal("failed approve appended an approved event")
}
}
verified := make(map[domain.Peer]bool)
applyVerified := func(_ context.Context, decided domain.VerificationApplication) error {
if decided.Status != domain.VerificationStatusApproved {
return fmt.Errorf("callback saw %s, want approved", decided.Status)
}
verified[decided.Target()] = true
return nil
}
approved, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a",
InternalNote: "checked the press coverage", CorrelationID: "cmd-1",
}, true, applyVerified)
if err != nil || !changed {
t.Fatalf("approve: changed=%v err=%v", changed, err)
}
if approved.Status != domain.VerificationStatusApproved || approved.ReviewedAt.IsZero() ||
approved.ReviewerAdminID != "admin-a" || approved.Version != claimed.Version+1 {
t.Fatalf("approved = %s v%d by %q at %v", approved.Status, approved.Version,
approved.ReviewerAdminID, approved.ReviewedAt)
}
if !verified[approved.Target()] {
t.Fatal("approved application whose target is not verified")
}
// Re-issuing the decision must not notify twice.
repeat, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: approved.Version, Reviewer: "admin-b",
}, true, func(context.Context, domain.VerificationApplication) error {
t.Fatal("idempotent approve invoked the callback")
return nil
})
if err != nil || changed {
t.Fatalf("repeat approve: changed=%v err=%v", changed, err)
}
if repeat.Version != approved.Version || repeat.ReviewerAdminID != "admin-a" {
t.Fatalf("repeat approve mutated the record: v%d by %q", repeat.Version, repeat.ReviewerAdminID)
}
pending, err = s.PendingVerificationNotifications(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 1 || pending[0].Kind != "approved" || pending[0].RecipientUserID != 1001 {
t.Fatalf("outbox = %+v, want one approved row for the applicant", pending)
}
if pending[0].Application.ID != app.ID || pending[0].Application.TargetUsername != "gamma" {
t.Fatalf("outbox row carries no application context: %+v", pending[0].Application)
}
// A decided application is terminal.
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: approved.Version, Reviewer: "admin-a", Reason: "changed our mind",
}, false, nil); !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("reject after approve err = %v, want ErrVerificationStatusInvalid", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: approved.Version, Reviewer: "admin-a",
}, true, nil); err == nil {
t.Fatal("approve without a callback succeeded")
}
}
// TestMemoryVerificationRejectRequiresReason keeps the audit trail honest: a
// rejection the applicant is told about always states why.
func TestMemoryVerificationRejectRequiresReason(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 8001, "delta")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
}, false, nil); !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("reject without reason err = %v, want ErrVerificationReasonRequired", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: " ", Reason: "not eligible",
}, false, nil); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("reject without reviewer err = %v, want ErrVerificationApplicationInvalid", err)
}
rejected, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
Reason: "press coverage is not independent", InternalNote: "second attempt this month",
}, false, nil)
if err != nil || !changed {
t.Fatalf("reject: changed=%v err=%v", changed, err)
}
if rejected.Status != domain.VerificationStatusRejected || rejected.DecisionReason == "" {
t.Fatalf("rejected = %s reason=%q", rejected.Status, rejected.DecisionReason)
}
cooldown, err := s.LastVerificationRejection(ctx, 1001, domain.VerificationTargetChannel, 8001)
if err != nil || cooldown.ID != app.ID {
t.Fatalf("cooldown lookup = %d err=%v", cooldown.ID, err)
}
if _, err := s.LastVerificationRejection(ctx, 1002, domain.VerificationTargetChannel, 8001); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("cooldown for another applicant err = %v, want ErrVerificationApplicationNotFound", err)
}
// The rejected application is history, so the target is free again, and the
// newest rejection is the one the cooldown is measured from.
second := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 8001, "delta")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: second.ID, Version: second.Version, Reviewer: "admin-b", Reason: "still no",
}, false, nil); err != nil {
t.Fatalf("second reject: %v", err)
}
cooldown, err = s.LastVerificationRejection(ctx, 1001, domain.VerificationTargetChannel, 8001)
if err != nil || cooldown.ID != second.ID {
t.Fatalf("newest rejection = %d err=%v, want %d", cooldown.ID, err, second.ID)
}
}
// TestMemoryVerificationConcurrentDecision is the two-reviewers case: both read
// the same version, exactly one decision lands and the loser is told.
func TestMemoryVerificationConcurrentDecision(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9001, "epsilon")
calls := 0
applyVerified := func(context.Context, domain.VerificationApplication) error {
calls++
return nil
}
if _, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
}, true, applyVerified); err != nil || !changed {
t.Fatalf("first approve: changed=%v err=%v", changed, err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-b",
}, true, applyVerified); !errors.Is(err, domain.ErrVerificationVersionConflict) {
t.Fatalf("second approve err = %v, want ErrVerificationVersionConflict", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-b", Reason: "no",
}, false, nil); !errors.Is(err, domain.ErrVerificationVersionConflict) {
t.Fatalf("losing reject err = %v, want ErrVerificationVersionConflict", err)
}
if calls != 1 {
t.Fatalf("applyVerified called %d times, want exactly 1", calls)
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 1 {
t.Fatalf("outbox = %d rows err=%v, want exactly one notification", len(pending), err)
}
final, err := s.VerificationApplication(ctx, app.ID)
if err != nil || final.ReviewerAdminID != "admin-a" {
t.Fatalf("final decision by %q err=%v, want admin-a", final.ReviewerAdminID, err)
}
}
// TestMemoryVerificationRevoke covers taking the badge back: the flag is cleared
// through the callback, the application stays approved as history and the
// revocation notifies exactly once.
func TestMemoryVerificationRevoke(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 9101, "zeta")
approved, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
}, true, func(context.Context, domain.VerificationApplication) error { return nil })
if err != nil {
t.Fatalf("approve: %v", err)
}
req := domain.VerificationRevocation{
TargetType: domain.VerificationTargetChannel, TargetID: 9101,
Reviewer: "admin-b", Reason: "impersonation report upheld",
}
if _, _, err := s.RevokeVerification(ctx, domain.VerificationRevocation{
TargetType: domain.VerificationTargetChannel, TargetID: 9101, Reviewer: "admin-b",
}, func(context.Context, domain.Peer) error { return nil }); !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("revoke without reason err = %v, want ErrVerificationReasonRequired", err)
}
failing := errors.New("peer store unavailable")
if _, _, err := s.RevokeVerification(ctx, req, func(context.Context, domain.Peer) error {
return failing
}); !errors.Is(err, failing) {
t.Fatalf("failing revoke err = %v, want %v", err, failing)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 20)
if err != nil {
t.Fatalf("events: %v", err)
}
for _, event := range events {
if event.Kind == domain.VerificationEventRevoked {
t.Fatal("failed revoke appended a revoked event")
}
}
var cleared []domain.Peer
revoked, changed, err := s.RevokeVerification(ctx, req, func(_ context.Context, target domain.Peer) error {
cleared = append(cleared, target)
return nil
})
if err != nil || !changed {
t.Fatalf("revoke: changed=%v err=%v", changed, err)
}
if len(cleared) != 1 || cleared[0] != (domain.Peer{Type: domain.PeerTypeChannel, ID: 9101}) {
t.Fatalf("cleared = %v, want the channel peer", cleared)
}
if revoked.ID != approved.ID || revoked.Status != domain.VerificationStatusApproved {
t.Fatalf("revoked application = %d %s, want %d approved", revoked.ID, revoked.Status, approved.ID)
}
// A second revocation is a no-op: one outbox row, one history entry.
repeat, changed, err := s.RevokeVerification(ctx, req, func(context.Context, domain.Peer) error {
t.Fatal("idempotent revoke invoked the callback")
return nil
})
if err != nil || changed {
t.Fatalf("repeat revoke: changed=%v err=%v", changed, err)
}
if repeat.ID != approved.ID {
t.Fatalf("repeat revoke returned %d, want %d", repeat.ID, approved.ID)
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
kinds := make([]string, 0, len(pending))
for _, item := range pending {
kinds = append(kinds, item.Kind)
}
if len(kinds) != 2 || kinds[0] != "approved" || kinds[1] != "revoked" {
t.Fatalf("outbox kinds = %v, want [approved revoked] in that order", kinds)
}
// A target nobody ever applied for is still cleared: a standing flag is worse
// than a missing audit row.
orphan, changed, err := s.RevokeVerification(ctx, domain.VerificationRevocation{
TargetType: domain.VerificationTargetBot, TargetID: 9999,
Reviewer: "admin-b", Reason: "manual flag from an older deployment",
}, func(context.Context, domain.Peer) error { return nil })
if err != nil || !changed || orphan.ID != 0 {
t.Fatalf("orphan revoke: app=%d changed=%v err=%v", orphan.ID, changed, err)
}
}
// TestMemoryVerificationHistoryOrder pins the append-only timeline: newest first,
// with the from/to statuses of every transition.
func TestMemoryVerificationHistoryOrder(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9201, "eta")
claimed, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
})
if err != nil {
t.Fatalf("claim: %v", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a", CorrelationID: "cmd-9",
}, true, func(context.Context, domain.VerificationApplication) error { return nil }); err != nil {
t.Fatalf("approve: %v", err)
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 1 {
t.Fatalf("pending = %d err=%v", len(pending), err)
}
if err := s.MarkVerificationNotificationDelivered(ctx, pending[0].ID); err != nil {
t.Fatalf("deliver: %v", err)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 20)
if err != nil {
t.Fatalf("events: %v", err)
}
wantKinds := []domain.VerificationApplicationEventKind{
domain.VerificationEventNotified,
domain.VerificationEventApproved,
domain.VerificationEventClaimed,
domain.VerificationEventSubmitted,
domain.VerificationEventCreated,
}
if len(events) != len(wantKinds) {
t.Fatalf("history = %d rows, want %d", len(events), len(wantKinds))
}
for i, kind := range wantKinds {
if events[i].Kind != kind {
t.Fatalf("history[%d] = %s, want %s", i, events[i].Kind, kind)
}
if i > 0 && events[i].ID >= events[i-1].ID {
t.Fatalf("history is not newest-first at %d: %d >= %d", i, events[i].ID, events[i-1].ID)
}
}
approvedEvent := events[1]
if approvedEvent.FromStatus != domain.VerificationStatusInReview ||
approvedEvent.ToStatus != domain.VerificationStatusApproved ||
approvedEvent.Actor != "admin-a" || approvedEvent.CorrelationID != "cmd-9" {
t.Fatalf("approved event = %+v", approvedEvent)
}
if events[3].FromStatus != domain.VerificationStatusDraft ||
events[3].ToStatus != domain.VerificationStatusSubmitted {
t.Fatalf("submitted event = %+v", events[3])
}
}
// TestMemoryVerificationOutboxDelivery walks one notification from pending to
// delivered, including the failure trace a poisoned row leaves behind.
func TestMemoryVerificationOutboxDelivery(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9301, "theta")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a", Reason: "no press",
}, false, nil); err != nil {
t.Fatalf("reject: %v", err)
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 1 || pending[0].Attempts != 0 {
t.Fatalf("pending = %+v err=%v", pending, err)
}
id := pending[0].ID
if err := s.MarkVerificationNotificationFailed(ctx, id, "bot blocked by user"); err != nil {
t.Fatalf("fail: %v", err)
}
pending, err = s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 1 || pending[0].Attempts != 1 {
t.Fatalf("after failure = %+v err=%v, want still pending with 1 attempt", pending, err)
}
if err := s.MarkVerificationNotificationDelivered(ctx, id); err != nil {
t.Fatalf("deliver: %v", err)
}
pending, err = s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 0 {
t.Fatalf("after delivery = %d rows err=%v, want none", len(pending), err)
}
if err := s.MarkVerificationNotificationDelivered(ctx, id); err != nil {
t.Fatalf("repeat deliver: %v", err)
}
if err := s.MarkVerificationNotificationFailed(ctx, id, "late error"); err != nil {
t.Fatalf("fail after delivery: %v", err)
}
if err := s.MarkVerificationNotificationDelivered(ctx, id+1000); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("deliver unknown id err = %v, want ErrVerificationApplicationNotFound", err)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 20)
if err != nil {
t.Fatalf("events: %v", err)
}
if events[0].Kind != domain.VerificationEventNotified || events[0].Reason != "rejected" {
t.Fatalf("notified event = %+v, want the rejected notification", events[0])
}
}
// TestMemoryVerificationQueueQueries covers the review-queue projection: status
// and target filters, reviewer scoping, the three search shapes and keyset paging.
func TestMemoryVerificationQueueQueries(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
first := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9401, "AlphaBot")
claimed, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: first.ID, Version: first.Version, Reviewer: "admin-a",
})
if err != nil {
t.Fatalf("claim: %v", err)
}
second := submittedVerificationApplication(t, s, 1002, domain.VerificationTargetChannel, 9402, "BetaChannel")
third, _, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1003, domain.VerificationTargetSupergroup, 9403, "gammagroup"))
if err != nil {
t.Fatalf("third draft: %v", err)
}
ids := func(apps []domain.VerificationApplication) []int64 {
out := make([]int64, 0, len(apps))
for _, app := range apps {
out = append(out, app.ID)
}
return out
}
equal := func(got []int64, want ...int64) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
all, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{})
if err != nil {
t.Fatalf("list: %v", err)
}
if !equal(ids(all), third.ID, second.ID, first.ID) {
t.Fatalf("queue order = %v, want newest first", ids(all))
}
got, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Statuses: []domain.VerificationStatus{domain.VerificationStatusSubmitted, domain.VerificationStatusInReview},
})
if err != nil || !equal(ids(got), second.ID, first.ID) {
t.Fatalf("status filter = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
TargetType: domain.VerificationTargetChannel,
})
if err != nil || !equal(ids(got), second.ID) {
t.Fatalf("target type filter = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Reviewer: "admin-a"})
if err != nil || !equal(ids(got), first.ID) {
t.Fatalf("reviewer filter = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Reviewer: "admin-z"})
if err != nil || len(got) != 0 {
t.Fatalf("unknown reviewer = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
CreatedAt: second.CreatedAt,
})
if err != nil || !equal(ids(got), third.ID, second.ID) {
t.Fatalf("since filter = %v err=%v", ids(got), err)
}
// BeforeID without a cursor timestamp still bounds the page.
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
BeforeID: second.ID,
})
if err != nil || !equal(ids(got), first.ID) {
t.Fatalf("id-only cursor = %v err=%v", ids(got), err)
}
// Search: application id, peer id, username prefix (with and without @).
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Query: fmt.Sprint(first.ID),
})
if err != nil || !equal(ids(got), first.ID) {
t.Fatalf("id search = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "9402"})
if err != nil || !equal(ids(got), second.ID) {
t.Fatalf("peer id search = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "@beta"})
if err != nil || !equal(ids(got), second.ID) {
t.Fatalf("username search = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "ALPHA"})
if err != nil || !equal(ids(got), first.ID) {
t.Fatalf("case-insensitive username search = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "nobody"})
if err != nil || len(got) != 0 {
t.Fatalf("miss search = %v err=%v", ids(got), err)
}
// Keyset paging over (created_at DESC, id DESC).
page, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Limit: 2})
if err != nil || !equal(ids(page), third.ID, second.ID) {
t.Fatalf("first page = %v err=%v", ids(page), err)
}
last := page[len(page)-1]
next, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Limit: 2, Until: last.CreatedAt, BeforeID: last.ID,
})
if err != nil || !equal(ids(next), first.ID) {
t.Fatalf("second page = %v err=%v", ids(next), err)
}
last = next[len(next)-1]
tail, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Limit: 2, Until: last.CreatedAt, BeforeID: last.ID,
})
if err != nil || len(tail) != 0 {
t.Fatalf("third page = %v err=%v, want empty", ids(tail), err)
}
counts, err := s.VerificationStatusCounts(ctx)
if err != nil {
t.Fatalf("counts: %v", err)
}
if counts[domain.VerificationStatusInReview] != 1 ||
counts[domain.VerificationStatusSubmitted] != 1 ||
counts[domain.VerificationStatusDraft] != 1 ||
counts[domain.VerificationStatusApproved] != 0 {
t.Fatalf("counts = %v", counts)
}
mine, err := s.VerificationApplicationsForApplicant(ctx, 1001, 10)
if err != nil || !equal(ids(mine), first.ID) {
t.Fatalf("applicant history = %v err=%v", ids(mine), err)
}
_ = claimed
if _, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Statuses: []domain.VerificationStatus{"bogus"},
}); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("bogus status filter err = %v, want ErrVerificationApplicationInvalid", err)
}
if _, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
TargetType: "bogus",
}); !errors.Is(err, domain.ErrVerificationTargetInvalid) {
t.Fatalf("bogus target filter err = %v, want ErrVerificationTargetInvalid", err)
}
}
// TestMemoryVerificationMissingApplication pins the not-found surface every
// mutation shares.
func TestMemoryVerificationMissingApplication(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
if _, err := s.VerificationApplication(ctx, 42); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("read err = %v", err)
}
if _, err := s.SubmitVerificationApplication(ctx, 42, 1); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("submit err = %v", err)
}
if _, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: 42, Version: 1, Reviewer: "admin-a",
}); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("claim err = %v", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: 42, Version: 1, Reviewer: "admin-a",
}, true, func(context.Context, domain.VerificationApplication) error {
return nil
}); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("decide err = %v", err)
}
}

View file

@ -16,6 +16,8 @@ type MessageStore interface {
GetOutboxReadDate(ctx context.Context, req domain.OutboxReadDateRequest) (int, error)
SetMessageReactions(ctx context.Context, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error)
GetMessageReactions(ctx context.Context, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error)
ListSavedReactionTags(ctx context.Context, req domain.SavedReactionTagsRequest) ([]domain.SavedReactionTag, error)
UpsertSavedReactionTag(ctx context.Context, tag domain.SavedReactionTag) error
VoteMessagePoll(ctx context.Context, req domain.VotePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error)
CloseMessagePoll(ctx context.Context, req domain.ClosePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error)
EditMessage(ctx context.Context, req domain.EditMessageRequest) (domain.EditMessageResult, error)

View file

@ -0,0 +1,64 @@
package store
import (
"context"
"time"
"telesrv/internal/domain"
)
// ModerationReportStore atomically persists an immutable report, all evidence
// items and media holds. A retry with the same fingerprint returns the original
// report and created=false; implementations must never create partial items.
type ModerationReportStore interface {
CreateModerationReport(ctx context.Context, report domain.ModerationReport) (stored domain.ModerationReport, created bool, err error)
GetModerationReport(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error)
}
type ModerationEvidenceRegistryStore interface {
CreateSponsoredMessageImpression(ctx context.Context, impression domain.SponsoredMessageImpression) (domain.SponsoredMessageImpression, bool, error)
GetSponsoredMessageImpression(ctx context.Context, userID int64, randomIDHash [32]byte, now time.Time) (domain.SponsoredMessageImpression, bool, error)
CreateSponsoredModerationReport(ctx context.Context, impressionID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error)
CreateChannelAntiSpamDecision(ctx context.Context, decision domain.ChannelAntiSpamDecision) (domain.ChannelAntiSpamDecision, bool, error)
GetChannelAntiSpamDecision(ctx context.Context, channelID int64, messageID int) (domain.ChannelAntiSpamDecision, bool, error)
CreateAntiSpamFalsePositiveReport(ctx context.Context, decisionID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error)
DeleteExpiredSponsoredMessageImpressions(ctx context.Context, olderThan time.Time, limit int) (int, error)
}
// LegacyEphemeralReport is an immutable row produced before ephemeral reports
// joined the unified moderation pipeline.
type LegacyEphemeralReport struct {
ID int64
Report domain.EphemeralAbuseReport
}
// LegacyEphemeralReportReader exposes only still-unmapped rows. It exists for
// the startup migration and must not be injected into RPC handlers.
type LegacyEphemeralReportReader interface {
ListUnmigratedEphemeralReports(ctx context.Context, limit int) ([]LegacyEphemeralReport, error)
}
// LegacyEphemeralReportImporter atomically inserts the unified report and its
// provenance mapping. Historical imports bypass submission rate limits but
// retain normal report validation and fingerprint idempotency.
type LegacyEphemeralReportImporter interface {
ImportLegacyEphemeralReport(ctx context.Context, legacyReportID int64, report domain.ModerationReport) (stored domain.ModerationReport, created bool, err error)
}
type ModerationCaseStore interface {
ListModerationCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error)
GetModerationCase(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error)
ClaimModerationCase(ctx context.Context, caseID, expectedVersion int64, actor string, now time.Time) (domain.ModerationCase, error)
DecideModerationCase(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
ReviewModerationAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
CreateModerationAppeal(ctx context.Context, appeal domain.ModerationAppeal) (domain.ModerationAppeal, bool, error)
GetModerationAppeal(ctx context.Context, appealID int64) (domain.ModerationAppeal, bool, error)
IssueModerationAppealLink(ctx context.Context, link domain.ModerationAppealLink) (domain.ModerationAppealLink, error)
GetModerationAppealLink(ctx context.Context, tokenHash [32]byte, now time.Time) (domain.ModerationAppealLink, bool, error)
SubmitModerationAppealByLink(ctx context.Context, tokenHash [32]byte, text string, now time.Time) (domain.ModerationAppeal, bool, error)
DeleteExpiredModerationAppealLinks(ctx context.Context, olderThan time.Time, limit int) (int, error)
ClaimModerationActions(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.ModerationAction, error)
IsModerationActionCurrent(ctx context.Context, action domain.ModerationAction) (bool, error)
SupersedeModerationAction(ctx context.Context, actionID int64, expectedAttempts int, now time.Time) error
CompleteModerationAction(ctx context.Context, actionID int64, expectedAttempts int, succeeded bool, errorText string, retryAt, now time.Time) error
}

View file

@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
@ -36,18 +35,17 @@ SELECT
email_unconfirmed_pattern, login_email_pattern, secure_random,
current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p,
srp_id, srp_verifier, srp_b_secret, srp_b,
recovery_email, recovery_code, recovery_code_expires_at, login_email
recovery_email, login_email
FROM account_passwords
WHERE user_id = $1`, userID)
var settings domain.PasswordSettings
var salt1, salt2, p []byte
var recoveryExpires sql.NullTime
if err := row.Scan(
&settings.HasRecovery, &settings.HasSecureValues, &settings.HasPassword, &settings.Hint,
&settings.EmailUnconfirmedPattern, &settings.LoginEmailPattern, &settings.SecureRandom,
&salt1, &salt2, &settings.NewAlgo.G, &p,
&settings.SRPID, &settings.SRPVerifier, &settings.SRPBSecret, &settings.SRPB,
&settings.RecoveryEmail, &settings.RecoveryCode, &recoveryExpires, &settings.LoginEmail,
&settings.RecoveryEmail, &settings.LoginEmail,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.PasswordSettings{}, false, nil
@ -65,9 +63,6 @@ WHERE user_id = $1`, userID)
settings.NewAlgo.Salt1 = append([]byte(nil), salt1...)
settings.NewAlgo.Salt2 = append([]byte(nil), salt2...)
settings.NewAlgo.P = append([]byte(nil), p...)
if recoveryExpires.Valid {
settings.RecoveryCodeExpiresAt = recoveryExpires.Time.Unix()
}
settings.SecureRandom = append([]byte(nil), settings.SecureRandom...)
settings.SRPVerifier = append([]byte(nil), settings.SRPVerifier...)
settings.SRPBSecret = append([]byte(nil), settings.SRPBSecret...)
@ -102,19 +97,15 @@ func (s *PasswordStore) Save(ctx context.Context, userID int64, settings domain.
if settings.CurrentAlgo != nil {
algo = *settings.CurrentAlgo
}
var recoveryExpires any
if settings.RecoveryCodeExpiresAt > 0 {
recoveryExpires = time.Unix(settings.RecoveryCodeExpiresAt, 0)
}
_, err := s.db.Exec(ctx, `
INSERT INTO account_passwords (
user_id, has_recovery, has_secure_values, has_password, hint,
email_unconfirmed_pattern, login_email_pattern, secure_random,
current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p,
srp_id, srp_verifier, srp_b_secret, srp_b,
recovery_email, recovery_code, recovery_code_expires_at, login_email
recovery_email, login_email
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
ON CONFLICT (user_id) DO UPDATE SET
has_recovery = EXCLUDED.has_recovery,
has_secure_values = EXCLUDED.has_secure_values,
@ -132,8 +123,6 @@ ON CONFLICT (user_id) DO UPDATE SET
srp_b_secret = EXCLUDED.srp_b_secret,
srp_b = EXCLUDED.srp_b,
recovery_email = EXCLUDED.recovery_email,
recovery_code = EXCLUDED.recovery_code,
recovery_code_expires_at = EXCLUDED.recovery_code_expires_at,
login_email = EXCLUDED.login_email,
updated_at = now()`,
userID,
@ -141,7 +130,7 @@ ON CONFLICT (user_id) DO UPDATE SET
settings.EmailUnconfirmedPattern, settings.LoginEmailPattern, nonNilBytea(settings.SecureRandom),
nonNilBytea(algo.Salt1), nonNilBytea(algo.Salt2), algo.G, nonNilBytea(algo.P),
settings.SRPID, nonNilBytea(settings.SRPVerifier), nonNilBytea(settings.SRPBSecret), nonNilBytea(settings.SRPB),
settings.RecoveryEmail, settings.RecoveryCode, recoveryExpires, settings.LoginEmail,
settings.RecoveryEmail, settings.LoginEmail,
)
if err != nil {
if isAccountPasswordLoginEmailUnique(err) {
@ -261,6 +250,41 @@ WHERE user_id = $1`, userID)
return settings, true, nil
}
func (s *PasswordStore) GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error) {
out := make(map[int64]domain.AccountSettings, len(userIDs))
if len(userIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
SELECT user_id, archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders,
hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button,
noncontact_peers_paid_stars, account_ttl_days, sensitive_content_enabled, contact_signup_silent
FROM account_settings
WHERE user_id = ANY($1::bigint[])`, userIDs)
if err != nil {
return nil, fmt.Errorf("get account settings batch: %w", err)
}
defer rows.Close()
for rows.Next() {
var userID int64
settings := domain.DefaultAccountSettings()
gp := &settings.GlobalPrivacy
if err := rows.Scan(
&userID,
&gp.ArchiveAndMuteNewNoncontactPeers, &gp.KeepArchivedUnmuted, &gp.KeepArchivedFolders,
&gp.HideReadMarks, &gp.NewNoncontactPeersRequirePremium, &gp.DisplayGiftsButton,
&gp.NoncontactPeersPaidStars, &settings.AccountTTLDays, &settings.SensitiveContentEnabled, &settings.ContactSignUpSilent,
); err != nil {
return nil, fmt.Errorf("scan account settings batch: %w", err)
}
out[userID] = settings
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate account settings batch: %w", err)
}
return out, nil
}
func (s *PasswordStore) SaveAccountSettings(ctx context.Context, userID int64, settings domain.AccountSettings) error {
gp := settings.GlobalPrivacy
if _, err := s.db.Exec(ctx, `

View file

@ -170,7 +170,7 @@ func (s *AccountLifecycleStore) ExecuteAccountDeletion(ctx context.Context, user
if err := purgeDeletedAccountPrivateState(ctx, tx, userID, now); err != nil {
return domain.AccountDeletionResult{}, err
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, ""); err != nil {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, "", ""); err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("release deleted account username: %w", err)
}
reason = strings.TrimSpace(reason)
@ -505,7 +505,10 @@ FROM authorizations WHERE auth_key_id = $1 AND user_id = $2 FOR UPDATE`, id, use
if !found {
return nil, nil
}
if err := deleteRevocationTargetsTx(ctx, tx, []int64{id}); err != nil {
// Cancelling a pending account deletion deliberately retires the requester
// protocol identity; unlike remote device revocation, this path does not need
// to preserve the key for a client-visible RPC 401 transition.
if err := deleteProtocolAuthIdentitiesTx(ctx, tx, []int64{id}); err != nil {
return nil, err
}
return []domain.Authorization{a}, nil

View file

@ -0,0 +1,557 @@
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// AccountRatingStore is the PostgreSQL implementation of the composite account
// rating read model and its contribution ledger.
//
// account_rating is derived state: it is always rebuildable from the contributing
// tables plus the 'manual' rows of account_rating_events, which is exactly what
// AccountRatingSignals gathers. Writes use optimistic concurrency on the stored
// version so a background recompute and an admin adjustment cannot silently
// overwrite each other.
type AccountRatingStore struct {
db sqlcgen.DBTX
}
// NewAccountRatingStore builds the store on a pgx pool or transaction.
func NewAccountRatingStore(db sqlcgen.DBTX) *AccountRatingStore {
return &AccountRatingStore{db: db}
}
var _ store.AccountRatingStore = (*AccountRatingStore)(nil)
const (
defaultAccountRatingListLimit = 50
maxAccountRatingListLimit = 200
)
const accountRatingColumns = `user_id, level, stars, current_level_stars, next_level_stars,
stars_component, activity_component, penalty_component, manual_component,
pending_stars, pending_date, computed_at, updated_at, version`
// accountRatingColumnsQualified is the same projection for queries that join, so
// the shared column names stay unambiguous.
const accountRatingColumnsQualified = `r.user_id, r.level, r.stars, r.current_level_stars, r.next_level_stars,
r.stars_component, r.activity_component, r.penalty_component, r.manual_component,
r.pending_stars, r.pending_date, r.computed_at, r.updated_at, r.version`
// AccountRating returns the stored projection, distinguishing "never computed"
// from "computed as zero".
func (s *AccountRatingStore) AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error) {
if s == nil || s.db == nil {
return domain.AccountRating{}, fmt.Errorf("account rating store is not configured")
}
if userID <= 0 {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
rating, err := scanAccountRating(s.db.QueryRow(ctx, `
SELECT `+accountRatingColumns+` FROM account_rating WHERE user_id = $1`, userID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
if err != nil {
return domain.AccountRating{}, fmt.Errorf("get account rating: %w", err)
}
return rating, nil
}
// AccountRatingBatch resolves several users in one round trip.
func (s *AccountRatingStore) AccountRatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
out := make(map[int64]domain.AccountRating, len(userIDs))
filtered := make([]int64, 0, len(userIDs))
for _, userID := range userIDs {
if userID > 0 {
filtered = append(filtered, userID)
}
}
if len(filtered) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
SELECT `+accountRatingColumns+` FROM account_rating WHERE user_id = ANY($1::bigint[])`, filtered)
if err != nil {
return nil, fmt.Errorf("list account ratings batch: %w", err)
}
defer rows.Close()
for rows.Next() {
rating, err := scanAccountRating(rows)
if err != nil {
return nil, fmt.Errorf("scan account rating batch: %w", err)
}
out[rating.UserID] = rating
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate account ratings batch: %w", err)
}
return out, nil
}
// SaveAccountRating upserts the projection under optimistic concurrency: the
// caller submits the version it intends to write (prev.Version + 1, which is what
// domain.ResolveAccountRatingPending produces), and the update only lands when the
// stored row is still one version behind. A stale write reports changed=false and
// returns the row that won, so the caller can recompute instead of retrying blind.
func (s *AccountRatingStore) SaveAccountRating(ctx context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
if s == nil || s.db == nil {
return domain.AccountRating{}, false, fmt.Errorf("account rating store is not configured")
}
if rating.UserID <= 0 || rating.Level < 0 || rating.Level > domain.MaxAccountRatingLevel ||
rating.CurrentLevelStars < 0 || rating.StarsComponent < 0 ||
rating.ActivityComponent < 0 || rating.PenaltyComponent < 0 {
return domain.AccountRating{}, false, domain.ErrAccountRatingAdjustmentInvalid
}
if rating.Version <= 0 {
rating.Version = 1
}
now := time.Now().UTC()
if rating.ComputedAt.IsZero() {
rating.ComputedAt = now
}
if rating.UpdatedAt.IsZero() {
rating.UpdatedAt = now
}
// The schema pairs pending_stars with pending_date; a half-filled pending
// record is normalised away rather than rejected by the CHECK at runtime.
if rating.PendingStars == 0 || rating.PendingDate.IsZero() {
rating.PendingStars = 0
rating.PendingDate = time.Time{}
}
var nextLevelStars any
if rating.HasNextLevel && rating.NextLevelStars > rating.CurrentLevelStars {
nextLevelStars = rating.NextLevelStars
}
var pendingDate any
if rating.PendingStars != 0 {
pendingDate = rating.PendingDate.UTC()
}
stored, err := scanAccountRating(s.db.QueryRow(ctx, `
INSERT INTO account_rating (
user_id, level, stars, current_level_stars, next_level_stars,
stars_component, activity_component, penalty_component, manual_component,
pending_stars, pending_date, computed_at, updated_at, version
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
ON CONFLICT (user_id) DO UPDATE SET
level = EXCLUDED.level,
stars = EXCLUDED.stars,
current_level_stars = EXCLUDED.current_level_stars,
next_level_stars = EXCLUDED.next_level_stars,
stars_component = EXCLUDED.stars_component,
activity_component = EXCLUDED.activity_component,
penalty_component = EXCLUDED.penalty_component,
manual_component = EXCLUDED.manual_component,
pending_stars = EXCLUDED.pending_stars,
pending_date = EXCLUDED.pending_date,
computed_at = EXCLUDED.computed_at,
updated_at = EXCLUDED.updated_at,
version = EXCLUDED.version
WHERE account_rating.version = EXCLUDED.version - 1
RETURNING `+accountRatingColumns,
rating.UserID, rating.Level, rating.Stars, rating.CurrentLevelStars, nextLevelStars,
rating.StarsComponent, rating.ActivityComponent, rating.PenaltyComponent, rating.ManualComponent,
rating.PendingStars, pendingDate, rating.ComputedAt.UTC(), rating.UpdatedAt.UTC(), rating.Version,
))
if errors.Is(err, pgx.ErrNoRows) {
// The guard rejected the write; report the row that is actually stored.
current, getErr := s.AccountRating(ctx, rating.UserID)
if getErr != nil {
return domain.AccountRating{}, false, getErr
}
return current, false, nil
}
if err != nil {
return domain.AccountRating{}, false, fmt.Errorf("save account rating: %w", err)
}
return stored, true, nil
}
// AccountRatingSignals gathers the raw contribution snapshot for one user.
//
// Sources, and why each one:
//
// stars received / spent stars_transactions, split by the sign of amount: that
// ledger is the single authoritative record of Stars
// movement for a user, and stars_transactions_user_id_idx
// (user_id, id DESC) bounds the scan to the user's rows.
// gifts received peer_star_gifts for the user peer, restricted to
// lifecycle_status = 'active' -- the weight rewards gifts
// actually held, not ones converted, burned or exported
// away. peer_star_gifts_owner_profile_order_idx is the
// partial index on exactly that predicate and leads with
// (owner_peer_type, owner_peer_id).
// moderation cases moderation_cases against this user peer, restricted to
// the statuses that follow a *violation* decision:
// 'action_pending' (violation decided, actions running),
// 'action_failed' (violation decided, action delivery
// broke) and 'resolved' (violation decided and actions
// applied). 'dismissed' covers both no_violation and a
// granted appeal, and 'open'/'in_review'/'appeal_review'
// are undecided, so none of them penalise the account.
// scam / fake users.scam / users.fake, the peer flags 0136 added.
// account age users.created_at, floored to whole days.
// messages sent message_boxes, counted through
// message_boxes_private_sender_live_idx
// (message_sender_id, private_message_id) WHERE NOT
// deleted. This is the cheapest trustworthy source: the
// index leads with the sender and is coverable, so the
// count needs no heap access. Every private message
// materialises one box per participant, hence
// count(DISTINCT private_message_id) rather than
// count(*). Channel posts are deliberately excluded --
// channel_messages has no sender-leading index, so
// attributing them would cost a full table scan.
// manual sum of account_rating_events.amount where
// kind = 'manual', which is the part of the score that
// must survive a full recompute.
func (s *AccountRatingStore) AccountRatingSignals(ctx context.Context, userID int64) (domain.AccountRatingSignals, error) {
if s == nil || s.db == nil {
return domain.AccountRatingSignals{}, fmt.Errorf("account rating store is not configured")
}
if userID <= 0 {
return domain.AccountRatingSignals{}, domain.ErrUserNotFound
}
signals := domain.AccountRatingSignals{UserID: userID}
err := s.db.QueryRow(ctx, `
SELECT
COALESCE((SELECT sum(amount) FROM stars_transactions WHERE user_id = u.id AND amount > 0), 0),
COALESCE((SELECT -sum(amount) FROM stars_transactions WHERE user_id = u.id AND amount < 0), 0),
COALESCE((
SELECT count(DISTINCT private_message_id) FROM message_boxes
WHERE message_sender_id = u.id AND NOT deleted
), 0),
GREATEST(0, FLOOR(EXTRACT(EPOCH FROM ($2::timestamptz - u.created_at)) / 86400))::bigint,
COALESCE((
SELECT count(*) FROM peer_star_gifts
WHERE owner_peer_type = 'user' AND owner_peer_id = u.id AND lifecycle_status = 'active'
), 0),
COALESCE((
SELECT count(*) FROM moderation_cases
WHERE target_peer_type = 'user' AND target_peer_id = u.id
AND status IN ('action_pending', 'action_failed', 'resolved')
), 0),
u.scam,
u.fake,
COALESCE((
SELECT sum(amount) FROM account_rating_events
WHERE user_id = u.id AND kind = 'manual'
), 0)
FROM users u
WHERE u.id = $1`, userID, time.Now().UTC()).Scan(
&signals.StarsReceived, &signals.StarsSpent, &signals.MessagesSent,
&signals.AccountAgeDays, &signals.GiftsReceived, &signals.ModerationCases,
&signals.Scam, &signals.Fake, &signals.Manual,
)
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountRatingSignals{}, domain.ErrUserNotFound
}
if err != nil {
return domain.AccountRatingSignals{}, fmt.Errorf("gather account rating signals: %w", err)
}
return signals, nil
}
// AdjustAccountRating appends a manual adjustment to the ledger. It does not
// recompute the projection: the caller pairs it with SaveAccountRating so the new
// manual total is folded in through the same formula as every other signal.
// Replaying the same CommandKey returns the recorded event and applied=false.
func (s *AccountRatingStore) AdjustAccountRating(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
if s == nil || s.db == nil {
return domain.AccountRatingEvent{}, false, fmt.Errorf("account rating store is not configured")
}
req.Reason = strings.TrimSpace(req.Reason)
req.Actor = strings.TrimSpace(req.Actor)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return domain.AccountRatingEvent{}, false, err
}
var event domain.AccountRatingEvent
applied := false
err := withTx(ctx, s.db, "adjust account rating", func(tx pgx.Tx) error {
if existing, found, err := accountRatingEventByCommandKey(ctx, tx, req.CommandKey); err != nil {
return err
} else if found {
event = existing
return nil
}
event = domain.AccountRatingEvent{
UserID: req.UserID,
Kind: domain.AccountRatingEventManual,
Amount: req.Amount,
Reason: req.Reason,
Actor: req.Actor,
CommandKey: req.CommandKey,
CreatedAt: time.Now().UTC(),
}
// DO NOTHING on the partial command_key index closes the window between the
// replay lookup and the insert: a concurrent retry of the same command
// records nothing and falls back to reading the row that won.
err := tx.QueryRow(ctx, `
INSERT INTO account_rating_events (user_id, kind, amount, reason, actor, command_key, created_at)
VALUES ($1,'manual',$2,$3,$4,NULLIF($5,''),$6)
ON CONFLICT (command_key) WHERE command_key IS NOT NULL DO NOTHING
RETURNING id`, event.UserID, event.Amount, event.Reason, event.Actor, event.CommandKey, event.CreatedAt).
Scan(&event.ID)
if errors.Is(err, pgx.ErrNoRows) {
existing, found, lookupErr := accountRatingEventByCommandKey(ctx, tx, req.CommandKey)
if lookupErr != nil {
return lookupErr
}
if !found {
return fmt.Errorf("insert account rating adjustment: conflicting command %q vanished", req.CommandKey)
}
event = existing
return nil
}
if err != nil {
return fmt.Errorf("insert account rating adjustment: %w", err)
}
applied = true
return nil
})
if err != nil {
return domain.AccountRatingEvent{}, false, err
}
return event, applied, nil
}
// ListAccountRatings is the admin leaderboard query. The order matches
// account_rating_leaderboard_idx (level DESC, stars DESC, user_id) and BeforeID is
// a keyset cursor: the cursor row's own (level, stars) are read back so paging
// stays consistent across the compound order instead of only over user ids.
func (s *AccountRatingStore) ListAccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
if filter.MinLevel < 0 || filter.MinLevel > domain.MaxAccountRatingLevel {
return nil, domain.ErrAccountRatingAdjustmentInvalid
}
limit := filter.Limit
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
if limit > maxAccountRatingListLimit {
limit = maxAccountRatingListLimit
}
rows, err := s.db.Query(ctx, `
WITH cursor_row AS (
SELECT level AS c_level, stars AS c_stars, user_id AS c_user_id
FROM account_rating WHERE $3 <> 0 AND user_id = $3
)
SELECT `+accountRatingColumnsQualified+`
FROM account_rating r
LEFT JOIN cursor_row c ON true
WHERE r.level >= $1
AND ($2 = 0 OR r.user_id = $2)
AND (
c.c_user_id IS NULL
OR r.level < c.c_level
OR (r.level = c.c_level AND r.stars < c.c_stars)
OR (r.level = c.c_level AND r.stars = c.c_stars AND r.user_id > c.c_user_id)
)
ORDER BY r.level DESC, r.stars DESC, r.user_id
LIMIT $4`, filter.MinLevel, filter.UserID, filter.BeforeID, limit)
if err != nil {
return nil, fmt.Errorf("list account ratings: %w", err)
}
defer rows.Close()
out := make([]domain.AccountRating, 0, limit)
for rows.Next() {
rating, err := scanAccountRating(rows)
if err != nil {
return nil, fmt.Errorf("scan account rating: %w", err)
}
out = append(out, rating)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate account ratings: %w", err)
}
return out, nil
}
// AccountRatingEvents returns the ledger for one user, newest first, over
// account_rating_events_user_idx (user_id, id DESC).
func (s *AccountRatingStore) AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
if userID <= 0 {
return nil, domain.ErrAccountRatingNotFound
}
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
if limit > maxAccountRatingListLimit {
limit = maxAccountRatingListLimit
}
rows, err := s.db.Query(ctx, `
SELECT id, user_id, kind, amount, reason, actor, COALESCE(command_key, ''), created_at
FROM account_rating_events
WHERE user_id = $1
ORDER BY id DESC
LIMIT $2`, userID, limit)
if err != nil {
return nil, fmt.Errorf("list account rating events: %w", err)
}
defer rows.Close()
out := make([]domain.AccountRatingEvent, 0, limit)
for rows.Next() {
event, err := scanAccountRatingEvent(rows)
if err != nil {
return nil, fmt.Errorf("scan account rating event: %w", err)
}
out = append(out, event)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate account rating events: %w", err)
}
return out, nil
}
// StaleAccountRatings returns the users whose projection is older than the
// horizon, ordered so the walk follows account_rating_stale_idx
// (computed_at, user_id) exactly.
func (s *AccountRatingStore) StaleAccountRatings(ctx context.Context, olderThanUnix int64, limit int) ([]int64, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
if olderThanUnix <= 0 {
return nil, nil
}
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
if limit > maxAccountRatingListLimit {
limit = maxAccountRatingListLimit
}
rows, err := s.db.Query(ctx, `
SELECT user_id FROM account_rating
WHERE computed_at < to_timestamp($1)
ORDER BY computed_at, user_id
LIMIT $2`, olderThanUnix, limit)
if err != nil {
return nil, fmt.Errorf("list stale account ratings: %w", err)
}
defer rows.Close()
out := make([]int64, 0, limit)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
return nil, fmt.Errorf("scan stale account rating: %w", err)
}
out = append(out, userID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate stale account ratings: %w", err)
}
return out, nil
}
// UnratedAccounts returns accounts that have no projection yet, oldest account
// first so the walk is stable and every account is eventually reached.
//
// Three kinds of account are skipped, per domain.RatableAccount: bots, which do
// not transact on their own behalf; the built-in service accounts, which are
// infrastructure -- and note that the platform account is not flagged is_bot, so
// excluding bots alone would still have seeded it; and deleted accounts, which are
// tombstones whose every profile field has already been cleared.
func (s *AccountRatingStore) UnratedAccounts(ctx context.Context, limit int) ([]int64, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("account rating store is not configured")
}
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
if limit > maxAccountRatingListLimit {
limit = maxAccountRatingListLimit
}
rows, err := s.db.Query(ctx, `
SELECT u.id FROM users u
WHERE NOT u.is_bot
AND u.deleted_at IS NULL
AND u.id <> ALL($2::bigint[])
AND NOT EXISTS (SELECT 1 FROM account_rating r WHERE r.user_id = u.id)
ORDER BY u.created_at, u.id
LIMIT $1`, limit, domain.SystemUserIDs())
if err != nil {
return nil, fmt.Errorf("list unrated accounts: %w", err)
}
defer rows.Close()
out := make([]int64, 0, limit)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
return nil, fmt.Errorf("scan unrated account: %w", err)
}
out = append(out, userID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate unrated accounts: %w", err)
}
return out, nil
}
func accountRatingEventByCommandKey(ctx context.Context, db sqlcgen.DBTX, commandKey string) (domain.AccountRatingEvent, bool, error) {
if commandKey == "" {
return domain.AccountRatingEvent{}, false, nil
}
event, err := scanAccountRatingEvent(db.QueryRow(ctx, `
SELECT id, user_id, kind, amount, reason, actor, COALESCE(command_key, ''), created_at
FROM account_rating_events WHERE command_key = $1`, commandKey))
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountRatingEvent{}, false, nil
}
if err != nil {
return domain.AccountRatingEvent{}, false, fmt.Errorf("lookup account rating command: %w", err)
}
return event, true, nil
}
func scanAccountRating(row pgx.Row) (domain.AccountRating, error) {
var rating domain.AccountRating
var nextLevelStars pgtype.Int8
var pendingDate pgtype.Timestamptz
if err := row.Scan(&rating.UserID, &rating.Level, &rating.Stars, &rating.CurrentLevelStars,
&nextLevelStars, &rating.StarsComponent, &rating.ActivityComponent,
&rating.PenaltyComponent, &rating.ManualComponent, &rating.PendingStars,
&pendingDate, &rating.ComputedAt, &rating.UpdatedAt, &rating.Version); err != nil {
return domain.AccountRating{}, err
}
if nextLevelStars.Valid {
rating.NextLevelStars = nextLevelStars.Int64
rating.HasNextLevel = true
}
if pendingDate.Valid {
rating.PendingDate = pendingDate.Time.UTC()
}
rating.ComputedAt = rating.ComputedAt.UTC()
rating.UpdatedAt = rating.UpdatedAt.UTC()
return rating, nil
}
func scanAccountRatingEvent(row pgx.Row) (domain.AccountRatingEvent, error) {
var event domain.AccountRatingEvent
var kind string
if err := row.Scan(&event.ID, &event.UserID, &kind, &event.Amount, &event.Reason,
&event.Actor, &event.CommandKey, &event.CreatedAt); err != nil {
return domain.AccountRatingEvent{}, err
}
event.Kind = domain.AccountRatingEventKind(kind)
event.CreatedAt = event.CreatedAt.UTC()
return event, nil
}

View file

@ -0,0 +1,462 @@
package postgres
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
// ratingTestUser inserts a user row with an explicit creation date so the account
// age signal is deterministic.
func ratingTestUser(t *testing.T, pool *pgxpool.Pool, seed int64, createdAt time.Time) int64 {
t.Helper()
ctx := context.Background()
var id int64
if err := pool.QueryRow(ctx, `
INSERT INTO users (access_hash, phone, first_name, created_at, updated_at)
VALUES ($1, $2, 'rating test', $3, $3)
RETURNING id`, seed, fmt.Sprintf("%d", seed), createdAt).Scan(&id); err != nil {
t.Fatalf("insert rating test user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
})
return id
}
// ratingTestCatalogRevision publishes a throwaway star gift so peer_star_gifts
// rows can satisfy their catalog revision foreign key.
func ratingTestCatalogRevision(t *testing.T, pool *pgxpool.Pool) (revisionID, giftID int64) {
t.Helper()
ctx := context.Background()
suffix := randomSuffix(t)
docID := time.Now().UnixNano() & 0x7fffffffffffffff
entry, err := NewStarGiftStore(pool).CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Stars: 50, ConvertStars: 50, Enabled: true,
Document: domain.Document{
ID: docID, AccessHash: docID + 1, MimeType: "application/x-tgsticker", Size: 4, DCID: 2,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
},
Blob: domain.FileBlob{
LocationKey: "doc:" + fmt.Sprint(docID), Backend: domain.MediaBackendLocalFS,
ObjectKey: "rating-star-gift", Size: 4, SHA256: make([]byte, 32),
MimeType: "application/x-tgsticker",
},
Animation: domain.StarGiftAnimation{
JSON: []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`),
SHA256: make([]byte, 32), SourceFormat: domain.StarGiftAnimationTGS, Width: 512, Height: 512,
},
Actor: "test", CommandID: "rating-star-gift-" + suffix,
})
if err != nil {
t.Fatalf("create catalog revision: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT id FROM star_gift_catalog_revisions WHERE gift_id = $1 ORDER BY revision DESC LIMIT 1`,
entry.Gift.ID).Scan(&revisionID); err != nil {
t.Fatalf("read catalog revision id: %v", err)
}
t.Cleanup(func() {
cleanupCtx := context.Background()
_, _ = pool.Exec(cleanupCtx, `DELETE FROM star_gift_catalog WHERE gift_id = $1`, entry.Gift.ID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM star_gift_catalog_revisions WHERE gift_id = $1`, entry.Gift.ID)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM file_blobs WHERE location_key = $1`, "doc:"+fmt.Sprint(docID))
_, _ = pool.Exec(cleanupCtx, `DELETE FROM documents WHERE id = $1`, docID)
})
return revisionID, entry.Gift.ID
}
// TestAccountRatingSaveVersionConflict covers the optimistic write: a first save
// creates the row, a stale version is refused without an error, and the next
// version wins.
func TestAccountRatingSaveVersionConflict(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
userID := ratingTestUser(t, pool, 3_100_000_000+seed, time.Now().UTC().AddDate(0, 0, -30))
if _, err := store.AccountRating(ctx, userID); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("missing rating err = %v, want ErrAccountRatingNotFound", err)
}
now := time.Now().UTC().Truncate(time.Millisecond)
signals := domain.AccountRatingSignals{UserID: userID, StarsReceived: 900, MessagesSent: 10, AccountAgeDays: 30}
computed := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
stored, changed, err := store.SaveAccountRating(ctx, computed)
if err != nil || !changed {
t.Fatalf("first save changed=%v err=%v", changed, err)
}
if stored.Version != 1 || stored.Stars != computed.Stars || stored.Level != computed.Level ||
stored.HasNextLevel != computed.HasNextLevel || stored.NextLevelStars != computed.NextLevelStars {
t.Fatalf("stored = %+v want %+v", stored, computed)
}
read, err := store.AccountRating(ctx, userID)
if err != nil || read != stored {
t.Fatalf("read = %+v stored = %+v err=%v", read, stored, err)
}
// A second writer that still believes version 0 is stale: no error, no write.
stale := computed
stale.Stars = 999_999
conflicted, changed, err := store.SaveAccountRating(ctx, stale)
if err != nil || changed {
t.Fatalf("stale save changed=%v err=%v", changed, err)
}
if conflicted.Stars != stored.Stars || conflicted.Version != 1 {
t.Fatalf("conflicted = %+v, stored row must win", conflicted)
}
// The recompute that carries the right version applies, including a pending
// delta that must round-trip through the paired pending_stars/pending_date.
next := domain.ResolveAccountRatingPending(stored,
domain.ComputeAccountRating(domain.AccountRatingSignals{UserID: userID, StarsReceived: 40_000}, domain.DefaultAccountRatingWeights(), now),
time.Hour, now)
if next.PendingStars == 0 || next.PendingDate.IsZero() {
t.Fatalf("expected a parked pending delta, got %+v", next)
}
applied, changed, err := store.SaveAccountRating(ctx, next)
if err != nil || !changed {
t.Fatalf("versioned save changed=%v err=%v", changed, err)
}
if applied.Version != 2 || applied.PendingStars != next.PendingStars ||
!applied.PendingDate.Equal(next.PendingDate.UTC()) {
t.Fatalf("applied = %+v want pending %d at %v", applied, next.PendingStars, next.PendingDate)
}
pending, ok := applied.PendingLevel()
if !ok || pending.Stars <= applied.Stars {
t.Fatalf("pending projection = %+v ok=%v", pending, ok)
}
batch, err := store.AccountRatingBatch(ctx, []int64{userID, userID + 1})
if err != nil || len(batch) != 1 || batch[userID].Version != 2 {
t.Fatalf("batch = %+v err=%v", batch, err)
}
list, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{UserID: userID, Limit: 10})
if err != nil || len(list) != 1 || list[0].UserID != userID {
t.Fatalf("list = %+v err=%v", list, err)
}
stale2, err := store.StaleAccountRatings(ctx, now.Add(time.Minute).Unix(), 100)
if err != nil {
t.Fatalf("stale: %v", err)
}
if !containsInt64(stale2, userID) {
t.Fatalf("stale ratings %v must contain %d", stale2, userID)
}
fresh, err := store.StaleAccountRatings(ctx, now.Add(-time.Hour).Unix(), 100)
if err != nil {
t.Fatalf("stale fresh: %v", err)
}
if containsInt64(fresh, userID) {
t.Fatalf("rating computed at %v must not be stale before it", applied.ComputedAt)
}
}
// TestAccountRatingAdjustmentIdempotency covers the manual ledger: a replayed
// command key records nothing new and the manual total feeds the recompute.
func TestAccountRatingAdjustmentIdempotency(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
userID := ratingTestUser(t, pool, 3_200_000_000+seed, time.Now().UTC().AddDate(0, 0, -10))
key := fmt.Sprintf("adjust-%d", seed)
req := domain.AdjustAccountRatingRequest{
UserID: userID, Amount: 750, Reason: "community award", Actor: "ops", CommandKey: key,
}
event, applied, err := store.AdjustAccountRating(ctx, req)
if err != nil || !applied {
t.Fatalf("adjust applied=%v err=%v", applied, err)
}
if event.ID == 0 || event.Kind != domain.AccountRatingEventManual || event.Amount != 750 ||
event.CommandKey != key {
t.Fatalf("event = %+v", event)
}
replay, applied, err := store.AdjustAccountRating(ctx, req)
if err != nil || applied || replay.ID != event.ID {
t.Fatalf("replay applied=%v event=%+v err=%v", applied, replay, err)
}
// A second, distinct adjustment accumulates rather than replacing.
if _, applied, err := store.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: userID, Amount: -250, Reason: "partial revoke", Actor: "ops",
CommandKey: key + "-b",
}); err != nil || !applied {
t.Fatalf("second adjust applied=%v err=%v", applied, err)
}
events, err := store.AccountRatingEvents(ctx, userID, 10)
if err != nil || len(events) != 2 || events[0].Amount != -250 || events[1].Amount != 750 {
t.Fatalf("events = %+v err=%v", events, err)
}
if _, _, err := store.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: userID, Amount: 0, CommandKey: key + "-c",
}); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
t.Fatalf("zero adjustment err = %v, want ErrAccountRatingAdjustmentInvalid", err)
}
signals, err := store.AccountRatingSignals(ctx, userID)
if err != nil {
t.Fatalf("signals: %v", err)
}
if signals.Manual != 500 {
t.Fatalf("manual signal = %d, want 500", signals.Manual)
}
}
// TestAccountRatingSignalsSources pins where each contribution comes from: the
// Stars ledger sign split, saved gifts, upheld moderation cases, the peer flags,
// account age and the private-message count.
func TestAccountRatingSignalsSources(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
createdAt := time.Now().UTC().AddDate(0, 0, -45)
userID := ratingTestUser(t, pool, 3_300_000_000+seed, createdAt)
if _, err := pool.Exec(ctx, `
INSERT INTO stars_transactions (user_id, amount, reason, date)
VALUES ($1, 1500, 'gift', 0), ($1, 500, 'reaction', 0), ($1, -400, 'purchase', 0)`, userID); err != nil {
t.Fatalf("seed stars: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM stars_transactions WHERE user_id = $1`, userID)
})
revisionID, giftID := ratingTestCatalogRevision(t, pool)
if _, err := pool.Exec(ctx, `
INSERT INTO peer_star_gifts (owner_peer_type, owner_peer_id, msg_id, gift_id, gift_date, catalog_revision_id, lifecycle_status, converted)
VALUES ('user', $1, 1, $2, 0, $3, 'active', false),
('user', $1, 2, $2, 0, $3, 'active', false),
('user', $1, 3, $2, 0, $3, 'converted', true)`, userID, giftID, revisionID); err != nil {
t.Fatalf("seed gifts: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(),
`DELETE FROM peer_star_gifts WHERE owner_peer_type = 'user' AND owner_peer_id = $1`, userID)
})
// Only statuses that follow a violation decision count; 'dismissed' (which
// covers no_violation and a granted appeal) and undecided states do not.
now := time.Now().UTC()
for _, status := range []string{"resolved", "action_failed", "dismissed", "open"} {
if _, err := pool.Exec(ctx, `
INSERT INTO moderation_cases (
target_peer_type, target_peer_id, status, severity, report_count,
distinct_reporter_count, first_report_at, last_report_at, created_at, updated_at
) VALUES ('user', $1, $2, 1, 1, 1, $3, $3, $3, $3)`, userID, status, now); err != nil {
t.Fatalf("seed moderation case %s: %v", status, err)
}
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(),
`DELETE FROM moderation_cases WHERE target_peer_type = 'user' AND target_peer_id = $1`, userID)
})
if _, err := pool.Exec(ctx, `UPDATE users SET scam = true WHERE id = $1`, userID); err != nil {
t.Fatalf("set scam: %v", err)
}
// Two private messages, each materialised as the sender's and the recipient's
// box: the distinct count must report two, not four.
peerID := ratingTestUser(t, pool, 3_350_000_000+seed, createdAt)
for i := 1; i <= 2; i++ {
var messageID int64
if err := pool.QueryRow(ctx, `
INSERT INTO private_messages (sender_user_id, recipient_user_id, message_date, body)
VALUES ($1, $2, 0, 'hi')
RETURNING id`, userID, peerID).Scan(&messageID); err != nil {
t.Fatalf("seed private message: %v", err)
}
for _, box := range []struct {
owner int64
peer int64
outgoing bool
}{{userID, peerID, true}, {peerID, userID, false}} {
if _, err := pool.Exec(ctx, `
INSERT INTO message_boxes (
owner_user_id, box_id, private_message_id, message_sender_id, peer_type, peer_id,
from_user_id, message_date, outgoing, body
) VALUES ($1, $2, $3, $4, 'user', $5, $4, 0, $6, 'hi')`,
box.owner, i, messageID, userID, box.peer, box.outgoing); err != nil {
t.Fatalf("seed message box: %v", err)
}
}
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM message_boxes WHERE message_sender_id = $1`, userID)
_, _ = pool.Exec(context.Background(), `DELETE FROM private_messages WHERE sender_user_id = $1`, userID)
})
signals, err := store.AccountRatingSignals(ctx, userID)
if err != nil {
t.Fatalf("signals: %v", err)
}
if signals.UserID != userID || signals.StarsReceived != 2000 || signals.StarsSpent != 400 ||
signals.GiftsReceived != 2 || signals.ModerationCases != 2 || !signals.Scam || signals.Fake ||
signals.MessagesSent != 2 || signals.AccountAgeDays != 45 || signals.Manual != 0 {
t.Fatalf("signals = %+v", signals)
}
rating := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
if rating.PenaltyComponent == 0 || rating.StarsComponent != 2000+100 {
t.Fatalf("computed rating = %+v", rating)
}
if _, err := store.AccountRatingSignals(ctx, userID+7_000_000); !errors.Is(err, domain.ErrUserNotFound) {
t.Fatalf("signals for unknown user err = %v, want ErrUserNotFound", err)
}
}
// TestAccountRatingLeaderboardPaging covers the keyset walk over
// (level DESC, stars DESC, user_id).
func TestAccountRatingLeaderboardPaging(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
now := time.Now().UTC()
scores := []int64{100, 400, 900, 1600}
ids := make([]int64, 0, len(scores))
for i, score := range scores {
userID := ratingTestUser(t, pool, 3_400_000_000+seed+int64(i), now.AddDate(0, 0, -1))
ids = append(ids, userID)
rating := domain.ComputeAccountRating(domain.AccountRatingSignals{UserID: userID, Manual: score},
domain.DefaultAccountRatingWeights(), now)
if _, changed, err := store.SaveAccountRating(ctx, rating); err != nil || !changed {
t.Fatalf("save rating %d: changed=%v err=%v", userID, changed, err)
}
}
page, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: 2, Limit: 2})
if err != nil || len(page) != 2 {
t.Fatalf("first page = %+v err=%v", page, err)
}
if page[0].Level < page[1].Level {
t.Fatalf("leaderboard must be level-descending: %+v", page)
}
next, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{
MinLevel: 2, BeforeID: page[len(page)-1].UserID, Limit: 10,
})
if err != nil {
t.Fatalf("second page: %v", err)
}
for _, item := range next {
if item.UserID == page[0].UserID || item.UserID == page[1].UserID {
t.Fatalf("keyset page repeated user %d", item.UserID)
}
if item.Level > page[len(page)-1].Level {
t.Fatalf("keyset page went backwards: %+v after %+v", item, page)
}
}
if _, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: -1}); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
t.Fatalf("negative level filter must be rejected")
}
_ = ids
}
// TestAccountRatingUnratedAccountsSeedsTheReadModel proves the SQL behind the
// bootstrap pass: only accounts with no projection are returned, bots and deleted
// tombstones are excluded, the walk is oldest-account-first, and a user drops out of
// the candidate set the moment a projection exists.
//
// Without this query the read model can never populate itself -- StaleAccountRatings
// walks account_rating and so cannot return a user who is not in it.
func TestAccountRatingUnratedAccountsSeedsTheReadModel(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := NewAccountRatingStore(pool)
seed := time.Now().UnixNano() % 1_000_000
base := time.Now().UTC().Add(-72 * time.Hour).Truncate(time.Second)
oldest := ratingTestUser(t, pool, 4_100_000_000+seed, base)
middle := ratingTestUser(t, pool, 4_200_000_000+seed, base.Add(time.Hour))
newest := ratingTestUser(t, pool, 4_300_000_000+seed, base.Add(2*time.Hour))
bot := ratingTestUser(t, pool, 4_400_000_000+seed, base.Add(3*time.Hour))
deleted := ratingTestUser(t, pool, 4_500_000_000+seed, base.Add(4*time.Hour))
if _, err := pool.Exec(ctx, `UPDATE users SET is_bot = true WHERE id = $1`, bot); err != nil {
t.Fatalf("mark bot: %v", err)
}
// A deleted account is a tombstone: every profile field is already cleared, so
// there is no rating to show and no reason to compute one.
if _, err := pool.Exec(ctx, `
UPDATE users SET deleted_at = now(), deletion_source = 'manual', deletion_reason = 'test',
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
verified = false, support = false, premium_expires_at = NULL,
emoji_status_document_id = 0, emoji_status_until = 0,
emoji_status_collectible_id = NULL, emoji_status_collectible = '{}'::jsonb,
color_set = false, color = 0, color_background_emoji_id = 0,
profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0,
birthday_day = 0, birthday_month = 0, birthday_year = 0,
personal_channel_id = 0, last_seen_at = 0, account_delete_at = NULL
WHERE id = $1`, deleted); err != nil {
t.Fatalf("mark deleted: %v", err)
}
// The table is shared with every other account in the test database, so assert
// on relative order and membership rather than on an exact page.
positions := func(t *testing.T) (map[int64]int, []int64) {
t.Helper()
ids, err := store.UnratedAccounts(ctx, maxAccountRatingListLimit)
if err != nil {
t.Fatalf("UnratedAccounts: %v", err)
}
index := make(map[int64]int, len(ids))
for i, id := range ids {
index[id] = i
}
return index, ids
}
index, ids := positions(t)
for _, id := range []int64{oldest, middle, newest} {
if _, ok := index[id]; !ok {
t.Fatalf("account %d with no projection is absent from %d candidates", id, len(ids))
}
}
if _, ok := index[bot]; ok {
t.Fatalf("bot %d was offered as a rating candidate", bot)
}
if _, ok := index[deleted]; ok {
t.Fatalf("deleted account %d was offered as a rating candidate", deleted)
}
// The service accounts are infrastructure. The platform account in particular is
// NOT flagged is_bot, so excluding bots alone would still have seeded it -- which
// is exactly how it got a rating.
for _, serviceID := range domain.SystemUserIDs() {
if _, ok := index[serviceID]; ok {
t.Fatalf("service account %d was offered as a rating candidate", serviceID)
}
}
if !(index[oldest] < index[middle] && index[middle] < index[newest]) {
t.Fatalf("candidate order = oldest %d, middle %d, newest %d; want oldest first",
index[oldest], index[middle], index[newest])
}
// Seeding one account removes it from the candidate set, so the pass converges
// instead of offering the same user every cycle.
if _, changed, err := store.SaveAccountRating(ctx, domain.AccountRating{
UserID: middle, Level: 1, Stars: 150,
CurrentLevelStars: domain.AccountRatingLevelThreshold(1),
ComputedAt: time.Now().UTC(), Version: 1,
}); err != nil || !changed {
t.Fatalf("seed projection = %v changed=%v", err, changed)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM account_rating WHERE user_id = $1`, middle)
})
index, _ = positions(t)
if _, ok := index[middle]; ok {
t.Fatalf("account %d is still a candidate after being seeded", middle)
}
if _, ok := index[oldest]; !ok {
t.Fatalf("seeding %d removed unrelated candidate %d", middle, oldest)
}
// The limit is honoured, so one cycle can never walk the whole users table.
capped, err := store.UnratedAccounts(ctx, 2)
if err != nil {
t.Fatalf("UnratedAccounts with a limit: %v", err)
}
if len(capped) != 2 {
t.Fatalf("limited candidates = %d, want 2", len(capped))
}
}

View file

@ -0,0 +1,152 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
type AuthDeliveryReportStore struct {
db sqlcgen.DBTX
}
func NewAuthDeliveryReportStore(db sqlcgen.DBTX) *AuthDeliveryReportStore {
return &AuthDeliveryReportStore{db: db}
}
func (s *AuthDeliveryReportStore) CreateAuthDeliveryReport(ctx context.Context, report domain.AuthDeliveryReport) (domain.AuthDeliveryReport, bool, error) {
if s == nil || s.db == nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("auth delivery report store is not configured")
}
if err := report.Validate(); err != nil {
return domain.AuthDeliveryReport{}, false, err
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("auth delivery report store requires transaction-capable postgres handle")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("begin auth delivery report: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(
hashtextextended('auth-delivery:' || encode($1::bytea, 'hex'), 0)
)`, report.AuthKeyID[:]); err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("lock auth delivery reporter: %w", err)
}
existing, found, err := getAuthDeliveryReportByFingerprint(ctx, tx, report.AuthKeyID, report.Fingerprint)
if err != nil {
return domain.AuthDeliveryReport{}, false, err
}
if found {
return existing, false, nil
}
var hourly, phoneDaily int
if err := tx.QueryRow(ctx, `
SELECT
count(*) FILTER (
WHERE auth_key_id = $1 AND created_at >= $3::timestamptz - interval '1 hour'
),
count(*) FILTER (
WHERE phone_hash = $2 AND created_at >= $3::timestamptz - interval '24 hours'
)
FROM auth_delivery_reports
WHERE created_at <= $3::timestamptz
AND (auth_key_id = $1 OR phone_hash = $2)`,
report.AuthKeyID[:], report.PhoneHash[:], report.CreatedAt,
).Scan(&hourly, &phoneDaily); err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("count auth delivery reports: %w", err)
}
if hourly >= domain.MaxAuthDeliveryReportsPerHour ||
phoneDaily >= domain.MaxAuthDeliveryReportsPerPhoneDay {
return domain.AuthDeliveryReport{}, false, domain.ErrAuthDeliveryRateLimited
}
err = tx.QueryRow(ctx, `
INSERT INTO auth_delivery_reports (
auth_key_id, session_id, client_type, phone_hash, code_hash,
issued_user_id, delivery_id, channel, mnc, fingerprint, created_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING id`,
report.AuthKeyID[:], report.SessionID, report.ClientType,
report.PhoneHash[:], report.CodeHash[:], report.IssuedUserID,
report.DeliveryID, string(report.Channel), report.MNC,
report.Fingerprint[:], report.CreatedAt,
).Scan(&report.ID)
if err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("insert auth delivery report: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("commit auth delivery report: %w", err)
}
return report, true, nil
}
func getAuthDeliveryReportByFingerprint(ctx context.Context, db sqlcgen.DBTX, authKeyID [8]byte, fingerprint [32]byte) (domain.AuthDeliveryReport, bool, error) {
var report domain.AuthDeliveryReport
var storedAuthKey, phoneHash, codeHash, storedFingerprint []byte
var channel string
err := db.QueryRow(ctx, `
SELECT id, auth_key_id, session_id, client_type, phone_hash, code_hash,
issued_user_id, delivery_id, channel, mnc, fingerprint, created_at
FROM auth_delivery_reports
WHERE auth_key_id = $1 AND fingerprint = $2`,
authKeyID[:], fingerprint[:],
).Scan(
&report.ID, &storedAuthKey, &report.SessionID, &report.ClientType,
&phoneHash, &codeHash, &report.IssuedUserID, &report.DeliveryID,
&channel, &report.MNC, &storedFingerprint, &report.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return domain.AuthDeliveryReport{}, false, nil
}
if err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("get auth delivery report: %w", err)
}
if len(storedAuthKey) != len(report.AuthKeyID) ||
len(phoneHash) != len(report.PhoneHash) ||
len(codeHash) != len(report.CodeHash) ||
len(storedFingerprint) != len(report.Fingerprint) {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("get auth delivery report: invalid hash shape")
}
copy(report.AuthKeyID[:], storedAuthKey)
copy(report.PhoneHash[:], phoneHash)
copy(report.CodeHash[:], codeHash)
copy(report.Fingerprint[:], storedFingerprint)
report.Channel = domain.AuthCodeDeliveryKind(channel)
if err := report.Validate(); err != nil {
return domain.AuthDeliveryReport{}, false, fmt.Errorf("validate auth delivery report: %w", err)
}
return report, true, nil
}
func (s *AuthDeliveryReportStore) DeleteExpiredAuthDeliveryReports(ctx context.Context, olderThan time.Time, limit int) (int, error) {
if s == nil || s.db == nil {
return 0, fmt.Errorf("auth delivery report store is not configured")
}
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
return 0, domain.ErrAuthDeliveryReportInvalid
}
tag, err := s.db.Exec(ctx, `
WITH doomed AS (
SELECT id
FROM auth_delivery_reports
WHERE created_at < $1
ORDER BY created_at, id
LIMIT $2
)
DELETE FROM auth_delivery_reports r
USING doomed d
WHERE r.id = d.id`, olderThan, limit)
if err != nil {
return 0, fmt.Errorf("delete expired auth delivery reports: %w", err)
}
return int(tag.RowsAffected()), nil
}

View file

@ -0,0 +1,53 @@
package postgres
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
func TestAuthDeliveryReportPostgresIsIdempotentAndRetainedSeparately(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
createdAt := time.Unix(123_456, 0).UTC()
report, err := domain.NewAuthDeliveryReport(
[8]byte{1, 2, 3, 4, 5, 6, 7, byte(time.Now().UnixNano())},
time.Now().UnixNano(), "tdesktop", "15550004444",
"phone-code-hash", 77, "delivery-77",
domain.AuthCodeDeliverySMS, "46000", createdAt,
)
if err != nil {
t.Fatal(err)
}
store := NewAuthDeliveryReportStore(pool)
stored, created, err := store.CreateAuthDeliveryReport(ctx, report)
if err != nil || !created || stored.ID <= 0 {
t.Fatalf("stored=%+v created=%v err=%v", stored, created, err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM auth_delivery_reports WHERE id = $1", stored.ID)
})
retry, created, err := store.CreateAuthDeliveryReport(ctx, report)
if err != nil || created || retry.ID != stored.ID ||
retry.PhoneHash != stored.PhoneHash || retry.CodeHash != stored.CodeHash {
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
}
deleted, err := store.DeleteExpiredAuthDeliveryReports(
ctx, createdAt.Add(time.Second), 10,
)
if err != nil || deleted < 1 {
t.Fatalf("deleted=%d err=%v", deleted, err)
}
var moderationRows int
if err := pool.QueryRow(ctx, `
SELECT count(*)
FROM moderation_reports
WHERE reporter_user_id = $1`, report.IssuedUserID).Scan(&moderationRows); err != nil {
t.Fatal(err)
}
if moderationRows != 0 {
t.Fatalf("auth diagnostic leaked into moderation reports: %d", moderationRows)
}
}

View file

@ -195,8 +195,9 @@ WHERE auth_key_id = $1
//
// 同时清理把本 key 当作 perm key 的 temp auth key 行:temp_auth_key_bindings.temp_auth_key_id
// 侧有外键 ON DELETE CASCADE,删除 temp key 会自动清绑定;perm_auth_key_id 侧由
// RESTRICT FK 防止悬空,因此被踢/销毁 perm key 时必须先把关联 temp key 一并删掉。否则 Web/上传连接用
// raw temp key 重连时仍能进入 RPC 层,只得到 AUTH_KEY_UNREGISTERED,而不是连接层 404。
// RESTRICT FK 防止悬空,因此显式销毁 perm key 时必须先把关联 temp key 一并删掉。
// 远程撤销 authorization 不得调用本方法:被踢客户端必须保留协议 key,重连进入 RPC
// 层后取得 AUTH_KEY_UNREGISTERED,而不是只收到连接层 -404。
func (s *AuthKeyStore) Delete(ctx context.Context, id [8]byte) error {
return withAuthIdentityTx(ctx, s.db, "delete auth key", func(tx pgx.Tx) error {
return deleteAuthKeyTx(ctx, tx, authKeyIDToInt64(id))

View file

@ -252,9 +252,10 @@ RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_vers
return a, true, nil
}
// RevokeByHash 删除协议 auth_key 作为远程踢设备的持久化事实入口。
// authorizations 通过 FK cascade 删除;update_states 没有 auth_keys FK,必须显式清理;
// 关联 temp auth key 也显式删除,避免 raw temp key 重连。
// RevokeByHash 是远程踢设备的持久化事实入口:删除业务 authorization 与
// device update state,但保留 permanent/temp 协议 key 和 binding。这样被踢客户端
// 重连后仍可完成 MTProto 解密,并由 RPC gate 返回 AUTH_KEY_UNREGISTERED;若先删除
// 协议 key,客户端只能收到 transport -404,无法可靠清理本地登录态。
func (s *AuthorizationStore) RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
var (
a domain.Authorization
@ -315,7 +316,7 @@ FOR UPDATE`, candidate, userID, hash))
if !found {
return domain.Authorization{}, false, nil
}
if err := deleteRevocationTargetsTx(ctx, tx, []int64{candidate}); err != nil {
if err := deleteRevokedAuthorizationStateTx(ctx, tx, []int64{candidate}); err != nil {
return domain.Authorization{}, false, err
}
return a, true, nil
@ -349,7 +350,8 @@ RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_vers
return out, nil
}
// RevokeByUserExcept 批量删除协议 auth_key,保留 keepAuthKeyID 对应的当前设备。
// RevokeByUserExcept 批量删除业务 authorization,保留 keepAuthKeyID 对应的当前设备;
// 被撤销设备的协议 key/binding 保留,以便重连后取得 RPC 401 并完成客户端退出。
func (s *AuthorizationStore) RevokeByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
var out []domain.Authorization
err := withAuthIdentityTx(ctx, s.db, "revoke authorizations by user", func(tx pgx.Tx) error {
@ -449,13 +451,38 @@ FOR UPDATE`, userID, keepAuthKeyID, candidates)
for i := range out {
targets[i] = authKeyIDToInt64(out[i].AuthKeyID)
}
if err := deleteRevocationTargetsTx(ctx, tx, targets); err != nil {
if err := deleteRevokedAuthorizationStateTx(ctx, tx, targets); err != nil {
return nil, err
}
return out, nil
}
func deleteRevocationTargetsTx(ctx context.Context, tx pgx.Tx, authKeyIDs []int64) error {
func deleteRevokedAuthorizationStateTx(ctx context.Context, tx pgx.Tx, authKeyIDs []int64) error {
if len(authKeyIDs) == 0 {
return nil
}
if _, err := tx.Exec(ctx, `
DELETE FROM update_states
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs); err != nil {
return fmt.Errorf("delete revoked update states: %w", err)
}
tag, err := tx.Exec(ctx, `
DELETE FROM authorizations
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs)
if err != nil {
return fmt.Errorf("delete revoked authorizations: %w", err)
}
if tag.RowsAffected() != int64(len(authKeyIDs)) {
return fmt.Errorf("delete revoked authorizations: deleted %d of %d locked targets", tag.RowsAffected(), len(authKeyIDs))
}
return nil
}
// deleteProtocolAuthIdentitiesTx permanently removes permanent identities and
// their derived temp keys. Remote account.resetAuthorization/
// auth.resetAuthorizations must not use this helper: those clients need the
// protocol key long enough to reconnect and receive an RPC-level 401.
func deleteProtocolAuthIdentitiesTx(ctx context.Context, tx pgx.Tx, authKeyIDs []int64) error {
if len(authKeyIDs) == 0 {
return nil
}
@ -466,21 +493,21 @@ WHERE auth_key_id IN (
FROM temp_auth_key_bindings
WHERE perm_auth_key_id = ANY($1::bigint[])
)`, authKeyIDs); err != nil {
return fmt.Errorf("delete revoked temporary auth keys: %w", err)
return fmt.Errorf("delete temporary auth keys: %w", err)
}
if _, err := tx.Exec(ctx, `
DELETE FROM update_states
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs); err != nil {
return fmt.Errorf("delete revoked update states: %w", err)
return fmt.Errorf("delete protocol identity update states: %w", err)
}
tag, err := tx.Exec(ctx, `
DELETE FROM auth_keys
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs)
if err != nil {
return fmt.Errorf("delete revoked permanent auth keys: %w", err)
return fmt.Errorf("delete permanent auth keys: %w", err)
}
if tag.RowsAffected() != int64(len(authKeyIDs)) {
return fmt.Errorf("delete revoked permanent auth keys: deleted %d of %d locked targets", tag.RowsAffected(), len(authKeyIDs))
return fmt.Errorf("delete permanent auth keys: deleted %d of %d locked targets", tag.RowsAffected(), len(authKeyIDs))
}
return nil
}

View file

@ -2,7 +2,6 @@ package postgres
import (
"context"
"errors"
"fmt"
"testing"
"time"
@ -13,7 +12,7 @@ import (
"telesrv/internal/store"
)
func TestAuthorizationStoreRevokeByHashDeletesProtocolKeyCascadePostgres(t *testing.T) {
func TestAuthorizationStoreRevokeByHashKeepsProtocolIdentityPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
userID := createRevokeTestUser(t, ctx, pool, "hash")
@ -43,14 +42,15 @@ func TestAuthorizationStoreRevokeByHashDeletesProtocolKeyCascadePostgres(t *test
if err := NewUpdateStateStore(pool).Save(ctx, perm, userID, domain.UpdateState{Pts: 11, Date: int(time.Now().Unix())}); err != nil {
t.Fatalf("save update state: %v", err)
}
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
binding := domain.TempAuthKeyBinding{
TempAuthKeyID: temp,
PermAuthKeyID: authKeyIDToInt64(perm),
Nonce: 1,
TempSessionID: 2,
ExpiresAt: tempExpiry,
EncryptedMessage: []byte{1, 2, 3, 4},
}); err != nil {
}
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, binding); err != nil {
t.Fatalf("save temp binding: %v", err)
}
@ -61,14 +61,14 @@ func TestAuthorizationStoreRevokeByHashDeletesProtocolKeyCascadePostgres(t *test
if deleted.AuthKeyID != perm || !deleted.PasswordPending {
t.Fatalf("deleted authorization = %+v, want perm key and password_pending", deleted)
}
assertRevokeTestMissingAuthKey(t, ctx, keys, perm)
assertRevokeTestMissingAuthKey(t, ctx, keys, temp)
assertRevokeTestPresentAuthKey(t, ctx, keys, perm)
assertRevokeTestPresentAuthKey(t, ctx, keys, temp)
assertRevokeTestNoAuthorization(t, ctx, auths, perm)
assertRevokeTestTableCount(t, ctx, pool, "update_states", "auth_key_id", authKeyIDToInt64(perm), 0)
assertRevokeTestTableCount(t, ctx, pool, "temp_auth_key_bindings", "temp_auth_key_id", authKeyIDToInt64(temp), 0)
assertTempIdentityBinding(t, ctx, NewTempAuthKeyBindingStore(pool), binding)
}
func TestAuthorizationStoreRevokeByUserExceptDeletesOnlyRevokedKeysPostgres(t *testing.T) {
func TestAuthorizationStoreRevokeByUserExceptKeepsRevokedProtocolIdentitiesPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
userID := createRevokeTestUser(t, ctx, pool, "bulk")
@ -87,14 +87,15 @@ func TestAuthorizationStoreRevokeByUserExceptDeletesOnlyRevokedKeysPostgres(t *t
}
tempExpiry := int(time.Now().Add(time.Hour).Unix())
saveRevokeTestAuthKey(t, ctx, keys, tempForTwo, tempExpiry)
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
binding := domain.TempAuthKeyBinding{
TempAuthKeyID: tempForTwo,
PermAuthKeyID: authKeyIDToInt64(revokedTwo),
Nonce: 3,
TempSessionID: 4,
ExpiresAt: tempExpiry,
EncryptedMessage: []byte{5, 6, 7, 8},
}); err != nil {
}
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, binding); err != nil {
t.Fatalf("save temp binding: %v", err)
}
@ -107,9 +108,12 @@ func TestAuthorizationStoreRevokeByUserExceptDeletesOnlyRevokedKeysPostgres(t *t
}
assertRevokeTestPresentAuthKey(t, ctx, keys, keep)
assertRevokeTestPresentAuthorization(t, ctx, auths, keep)
assertRevokeTestMissingAuthKey(t, ctx, keys, revokedOne)
assertRevokeTestMissingAuthKey(t, ctx, keys, revokedTwo)
assertRevokeTestMissingAuthKey(t, ctx, keys, tempForTwo)
assertRevokeTestPresentAuthKey(t, ctx, keys, revokedOne)
assertRevokeTestPresentAuthKey(t, ctx, keys, revokedTwo)
assertRevokeTestPresentAuthKey(t, ctx, keys, tempForTwo)
assertRevokeTestNoAuthorization(t, ctx, auths, revokedOne)
assertRevokeTestNoAuthorization(t, ctx, auths, revokedTwo)
assertTempIdentityBinding(t, ctx, NewTempAuthKeyBindingStore(pool), binding)
}
func TestAuthorizationStoreUpdateClientInfoMergesPostgres(t *testing.T) {
@ -155,7 +159,7 @@ func TestAuthorizationStoreUpdateClientInfoMergesPostgres(t *testing.T) {
}
}
func TestAuthorizationStoreRevokeByHashConcurrentTempBindLeavesNoDanglingStatePostgres(t *testing.T) {
func TestAuthorizationStoreRevokeByHashConcurrentTempBindKeepsProtocolIdentityPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
userID := createRevokeTestUser(t, ctx, pool, "bind-revoke-race")
@ -203,28 +207,17 @@ func TestAuthorizationStoreRevokeByHashConcurrentTempBindLeavesNoDanglingStatePo
close(start)
bindErr := <-bindResult
if bindErr != nil && !errors.Is(bindErr, store.ErrAuthKeyBindingInvalid) {
if bindErr != nil {
t.Fatalf("attempt %d bind/revoke race bind error = %v", attempt, bindErr)
}
revoked := <-revokeResults
if revoked.err != nil || !revoked.found {
t.Fatalf("attempt %d bind/revoke race found=%v err=%v", attempt, revoked.found, revoked.err)
}
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || found {
t.Fatalf("attempt %d dangling binding found=%v err=%v", attempt, found, err)
}
assertRevokeTestMissingAuthKey(t, ctx, keys, perm)
assertTempIdentityBinding(t, ctx, bindings, candidate)
assertRevokeTestPresentAuthKey(t, ctx, keys, perm)
assertRevokeTestPresentAuthKey(t, ctx, keys, temp)
assertRevokeTestNoAuthorization(t, ctx, auths, perm)
if bindErr == nil {
assertRevokeTestMissingAuthKey(t, ctx, keys, temp)
} else {
assertTempIdentityAuthKeyExpiry(t, ctx, keys, temp, tempExpiry)
assertRevokeTestNoAuthorization(t, ctx, auths, temp)
if err := keys.Delete(ctx, temp); err != nil {
t.Fatalf("attempt %d clean unbound loser temp: %v", attempt, err)
}
assertRevokeTestMissingAuthKey(t, ctx, keys, temp)
}
}
}
@ -520,12 +513,10 @@ func TestAuthorizationStoreRevokeByUserExceptPartiallySkipsTransferredCandidateP
t.Fatalf("old A state for transferred key found=%v err=%v, want absent", found, err)
}
assertRevokeTestMissingAuthKey(t, testCtx, keys, revoked)
assertRevokeTestMissingAuthKey(t, testCtx, keys, revokedTemp)
assertRevokeTestPresentAuthKey(t, testCtx, keys, revoked)
assertRevokeTestPresentAuthKey(t, testCtx, keys, revokedTemp)
assertRevokeTestNoAuthorization(t, testCtx, auths, revoked)
if _, found, err := bindings.GetByTemp(testCtx, revokedTemp); err != nil || found {
t.Fatalf("revoked temp binding found=%v err=%v, want absent", found, err)
}
assertTempIdentityBinding(t, testCtx, bindings, revokedBinding)
if _, found, err := states.Get(testCtx, revoked, userA); err != nil || found {
t.Fatalf("revoked A state found=%v err=%v, want absent", found, err)
}

View file

@ -70,7 +70,7 @@ func (s *BotStore) CreateBotAccount(ctx context.Context, user domain.User, profi
return domain.User{}, domain.BotProfile{}, fmt.Errorf("create bot account: insert user: %w", err)
}
if usernameLower := strings.ToLower(row.Username); usernameLower != "" {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, usernameLower); err != nil {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, row.Username, usernameLower); err != nil {
return domain.User{}, domain.BotProfile{}, err
}
}
@ -139,7 +139,7 @@ func (s *BotStore) DeleteBotAccount(ctx context.Context, botUserID int64) (domai
if err := purgeDeletedAccountPrivateState(ctx, tx, botUserID, now); err != nil {
return domain.User{}, err
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, botUserID, ""); err != nil {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, botUserID, "", ""); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: release username: %w", err)
}
// Drop the bots row so the token can no longer authenticate a login.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -317,6 +317,7 @@ func (s *ChannelStore) GetChannels(ctx context.Context, viewerUserID int64, chan
SELECT `+channelColumns+`,
m.channel_id, m.user_id, m.inviter_user_id, m.role, m.status, m.joined_at, m.left_at,
m.admin_rights::text, m.banned_rights::text, m.rank, m.available_min_id, m.available_min_pts,
m.history_clear_anchor_id, m.history_clear_anchor_date,
m.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date
FROM channels c
JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1
@ -395,6 +396,10 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids)
if err != nil {
return nil, err
}
publicUsernameIDs, err := activeCollectibleUsernamePeerIDs(ctx, s.db, peerUsernameTypeChannel, remaining)
if err != nil {
return nil, err
}
for _, channel := range channels {
if member, ok := linkedGuests[channel.ID]; ok {
views[channel.ID] = domain.ChannelView{
@ -427,7 +432,8 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids)
continue
}
}
if !publicPreviewableChannel(channel) {
_, hasActiveUsername := publicUsernameIDs[channel.ID]
if !publicPreviewableChannel(channel, hasActiveUsername) {
continue
}
existing, found := previewMembers[channel.ID]
@ -514,17 +520,18 @@ func finishChannelScan(ch *domain.Channel, rights, reactionPolicy string, wallpa
}
}
func publicPreviewableChannel(channel domain.Channel) bool {
func publicPreviewableChannel(channel domain.Channel, hasActiveUsername bool) bool {
return !channel.Deleted &&
(channel.Broadcast || channel.Megagroup) &&
strings.TrimSpace(channel.Username) != ""
(strings.TrimSpace(channel.Username) != "" || hasActiveUsername)
}
func refreshChannelCountsTx(ctx context.Context, tx pgx.Tx, channel domain.Channel) (domain.Channel, error) {
var participants, admins, kicked, banned int
rows, err := tx.Query(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text,
rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
rank, available_min_id, available_min_pts, history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
FROM channel_members
WHERE channel_id = $1`, channel.ID)
if err != nil {

View file

@ -18,6 +18,26 @@ type channelDialogListItem struct {
defaultSendAs *domain.Peer
}
func channelDialogVisibleTopIDSQL() string {
return `CASE
WHEN c.top_message_id > m.available_min_id THEN c.top_message_id
WHEN m.history_clear_anchor_id > 0
AND m.history_clear_anchor_id = m.available_min_id
THEN m.history_clear_anchor_id
ELSE 0
END`
}
func channelDialogVisibleTopDateSQL(globalTopDate, emptyFallback string) string {
return `CASE
WHEN c.top_message_id > m.available_min_id THEN ` + globalTopDate + `
WHEN m.history_clear_anchor_id > 0
AND m.history_clear_anchor_id = m.available_min_id
THEN m.history_clear_anchor_date
ELSE ` + emptyFallback + `
END`
}
func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) {
if viewerUserID == 0 {
return domain.ChannelDialogList{}, nil
@ -33,8 +53,8 @@ func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int6
if len(channelIDs) == 0 {
return domain.ChannelDialogList{}, nil
}
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END"
visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE 0 END"
visibleTopID := channelDialogVisibleTopIDSQL()
visibleTopDate := channelDialogVisibleTopDateSQL("COALESCE(top_msg.message_date, d.top_message_date, c.date)", "0")
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
args := []any{viewerUserID, channelIDs}
@ -139,7 +159,9 @@ SELECT `+channelColumns+`,
COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type,
d.default_send_as_peer_id
d.default_send_as_peer_id,
m.history_clear_anchor_id,
m.history_clear_anchor_date
FROM channel_members m
JOIN channels c ON c.id = m.channel_id AND c.id = ANY($2::bigint[]) AND NOT c.deleted
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.channel_id = ANY($2::bigint[]) AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
@ -232,6 +254,7 @@ LIMIT `+limitArg, args...)
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil {
return domain.ChannelDialogList{}, err
}
projectChannelDialogHistoryClearMessages(out.Dialogs, out.Messages)
return out, nil
}
@ -255,7 +278,9 @@ SELECT `+channelColumns+`,
COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type,
d.default_send_as_peer_id
d.default_send_as_peer_id,
0,
0
FROM user_channel_member_index i
JOIN channel_members pm ON pm.user_id = i.user_id AND pm.channel_id = i.channel_id
JOIN channels parent ON parent.id = i.channel_id AND parent.id = ANY($2::bigint[]) AND parent.broadcast AND parent.linked_monoforum_id <> 0 AND NOT parent.deleted
@ -357,6 +382,20 @@ WHERE `+where.String(), args...)
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("scan channel dialog top messages: %w", err)
}
for _, dialog := range dialogs {
if dialog.Peer.Type != domain.PeerTypeChannel ||
dialog.TopMessage <= 0 ||
dialog.TopMessage != dialog.HistoryClearAnchorID {
continue
}
key := channelMessageLookupKey{channelID: dialog.Peer.ID, id: dialog.TopMessage}
out[key] = domain.ProjectChannelHistoryClearMessage(
out[key],
dialog.Peer.ID,
dialog.HistoryClearAnchorID,
dialog.HistoryClearAnchorDate,
)
}
return out, nil
}
@ -413,6 +452,14 @@ func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64
}
}
msg, _ := s.getChannelMessage(ctx, s.db, channelID, dialog.TopMessageID)
if dialog.TopMessageID > 0 && dialog.TopMessageID == dialog.HistoryClearAnchorID {
msg = domain.ProjectChannelHistoryClearMessage(
msg,
channelID,
dialog.HistoryClearAnchorID,
dialog.HistoryClearAnchorDate,
)
}
if msg.ID != 0 {
dialog.TopMessageDate = msg.Date
out.Messages = append(out.Messages, msg)
@ -425,9 +472,34 @@ func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil {
return domain.ChannelDialogList{}, err
}
projectChannelDialogHistoryClearMessages(out.Dialogs, out.Messages)
return out, nil
}
func projectChannelDialogHistoryClearMessages(dialogs []domain.Dialog, messages []domain.ChannelMessage) {
anchors := make(map[channelMessageLookupKey]domain.Dialog)
for _, dialog := range dialogs {
if dialog.Peer.Type != domain.PeerTypeChannel ||
dialog.TopMessage <= 0 ||
dialog.TopMessage != dialog.HistoryClearAnchorID {
continue
}
anchors[channelMessageLookupKey{channelID: dialog.Peer.ID, id: dialog.TopMessage}] = dialog
}
for i := range messages {
dialog, ok := anchors[channelMessageLookupKey{channelID: messages[i].ChannelID, id: messages[i].ID}]
if !ok {
continue
}
messages[i] = domain.ProjectChannelHistoryClearMessage(
messages[i],
dialog.Peer.ID,
dialog.HistoryClearAnchorID,
dialog.HistoryClearAnchorDate,
)
}
}
func (s *ChannelStore) ListCommonChannels(ctx context.Context, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) {
if req.UserID == 0 || req.TargetUserID == 0 || req.UserID == req.TargetUserID || req.MaxID < 0 {
return domain.CommonChannelsResult{}, domain.ErrChannelInvalid
@ -553,6 +625,7 @@ func (s *ChannelStore) leftChannelsByIDs(ctx context.Context, userID int64, ids
rows, err := s.db.Query(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at,
admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts,
history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
FROM channel_members
WHERE user_id = $1
@ -603,8 +676,8 @@ func (s *ChannelStore) ListInactiveChannels(ctx context.Context, userID int64, l
if len(channelIDs) == 0 {
return domain.ChannelDialogList{}, nil
}
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END"
visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE GREATEST(c.date, m.joined_at) END"
visibleTopID := channelDialogVisibleTopIDSQL()
visibleTopDate := channelDialogVisibleTopDateSQL("COALESCE(top_msg.message_date, d.top_message_date, c.date)", "GREATEST(c.date, m.joined_at)")
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
rows, err := s.db.Query(ctx, `
@ -619,7 +692,9 @@ SELECT `+channelColumns+`,
COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type,
d.default_send_as_peer_id
d.default_send_as_peer_id,
m.history_clear_anchor_id,
m.history_clear_anchor_date
FROM channel_members m
JOIN channels c ON c.id = m.channel_id AND c.id = ANY($2::bigint[]) AND NOT c.deleted
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.channel_id = ANY($2::bigint[]) AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
@ -856,6 +931,7 @@ func (s *ChannelStore) EditChannelPeerFolders(ctx context.Context, userID int64,
// 归档必须 ensure-INSERT:从未读过/置顶过的频道还没有 dialog 行,
// 只 UPDATE 会让归档静默丢失;新行同时带上 member 真实水位,
// 避免 0 水位缓存行遮蔽未读/已读状态。
visibleTopID := channelDialogVisibleTopIDSQL()
if _, err := s.db.Exec(ctx, `
WITH requested AS (
SELECT ($2::text[])[i] AS peer_type, ($3::bigint[])[i] AS channel_id, ($4::int[])[i] AS folder_id
@ -870,7 +946,7 @@ deduped AS (
)
INSERT INTO channel_dialogs (user_id, channel_id, folder_id, top_message_id, read_inbox_max_id, read_outbox_max_id, unread_count, updated_at)
SELECT $1, m.channel_id, deduped.folder_id,
CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END,
`+visibleTopID+`,
m.read_inbox_max_id, m.read_outbox_max_id,
(
SELECT COUNT(*)::int
@ -904,7 +980,7 @@ func (s *ChannelStore) CountChannelArchiveUnread(ctx context.Context, userID int
// JOIN active member:退群残留的 channel_dialogs 行不计入归档徽章。
// unread 读时动态派生(H4a):不再消费 channel_dialogs.unread_count 缓存列;归档集合
// 有界(需显式归档建行),每行动态 COUNT 已被 cap 钳制。
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END"
visibleTopID := channelDialogVisibleTopIDSQL()
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
if err := s.db.QueryRow(ctx, `
SELECT
@ -937,7 +1013,7 @@ func (s *ChannelStore) getChannelDialogUncached(ctx context.Context, db sqlcgen.
dialog := domain.ChannelDialog{UserID: userID, ChannelID: channel.ID, TopMessageID: channel.TopMessageID}
var defaultSendAsType sql.NullString
var defaultSendAsID sql.NullInt64
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END"
visibleTopID := channelDialogVisibleTopIDSQL()
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
// 单频道 TopMessageDate 用 LEFT JOIN top_msg 直接取,替代此前对每个频道再单查一次
@ -946,7 +1022,10 @@ func (s *ChannelStore) getChannelDialogUncached(ctx context.Context, db sqlcgen.
// message_date(不过滤 deleted,与原 getChannelMessage 一致),否则回退
// COALESCE(d.top_message_date, c.date)。注意:与批量版 getChannelDialogs 的隐藏态
// (ELSE 0)刻意保持各自既有差异,本改动只去往返、不改单频道输出。
visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id AND top_msg.id IS NOT NULL THEN top_msg.message_date ELSE COALESCE(d.top_message_date, c.date) END"
visibleTopDate := channelDialogVisibleTopDateSQL(
"CASE WHEN top_msg.id IS NOT NULL THEN top_msg.message_date ELSE COALESCE(d.top_message_date, c.date) END",
"COALESCE(d.top_message_date, c.date)",
)
err := db.QueryRow(ctx, `
SELECT `+visibleTopID+`,
`+visibleTopDate+`,
@ -962,7 +1041,9 @@ SELECT `+visibleTopID+`,
COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type,
d.default_send_as_peer_id
d.default_send_as_peer_id,
m.history_clear_anchor_id,
m.history_clear_anchor_date
FROM channels c
JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = c.id AND top_msg.id = c.top_message_id
@ -983,6 +1064,8 @@ WHERE c.id = $2`, userID, channel.ID).Scan(
&dialog.HasScheduled,
&defaultSendAsType,
&defaultSendAsID,
&dialog.HistoryClearAnchorID,
&dialog.HistoryClearAnchorDate,
)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return domain.ChannelDialog{}, fmt.Errorf("get channel dialog: %w", err)
@ -1037,8 +1120,8 @@ func (s *ChannelStore) getChannelDialogsUncached(ctx context.Context, db sqlcgen
if userID == 0 || len(channelIDs) == 0 {
return nil, nil
}
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END"
visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE 0 END"
visibleTopID := channelDialogVisibleTopIDSQL()
visibleTopDate := channelDialogVisibleTopDateSQL("COALESCE(top_msg.message_date, d.top_message_date, c.date)", "0")
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
rows, err := db.Query(ctx, `
@ -1057,7 +1140,9 @@ SELECT c.id,
COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type,
d.default_send_as_peer_id
d.default_send_as_peer_id,
m.history_clear_anchor_id,
m.history_clear_anchor_date
FROM channels c
JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = c.id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
@ -1089,6 +1174,8 @@ WHERE c.id = ANY($2::bigint[])`, userID, channelIDs)
&dialog.HasScheduled,
&defaultSendAsType,
&defaultSendAsID,
&dialog.HistoryClearAnchorID,
&dialog.HistoryClearAnchorDate,
); err != nil {
return nil, err
}
@ -1197,6 +1284,7 @@ func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.
var rights, reactionPolicy string
var wallpaper *string
var topID, topDate, folderID, readInbox, readOutbox, unreadCount, pinnedOrder, unreadMentions, unreadReactions int
var historyClearAnchorID, historyClearAnchorDate int
var pinned, unreadMark, viewForumAsMessages, hasScheduled bool
var defaultSendAsType sql.NullString
var defaultSendAsID sql.NullInt64
@ -1204,27 +1292,30 @@ func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.
&topID, &topDate,
&folderID, &readInbox, &readOutbox, &unreadCount, &pinned, &pinnedOrder, &unreadMark, &unreadMentions, &unreadReactions, &viewForumAsMessages, &hasScheduled,
&defaultSendAsType, &defaultSendAsID,
&historyClearAnchorID, &historyClearAnchorDate,
)
if err := row.Scan(dest...); err != nil {
return domain.Channel{}, domain.Dialog{}, nil, err
}
finishChannelScan(&ch, rights, reactionPolicy, wallpaper)
dialog := domain.Dialog{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID},
FolderID: folderID,
TopMessage: topID,
TopMessageDate: topDate,
ReadInboxMaxID: readInbox,
ReadOutboxMaxID: readOutbox,
UnreadCount: unreadCount,
UnreadMentions: unreadMentions,
UnreadReactions: unreadReactions,
Pinned: pinned,
PinnedOrder: pinnedOrder,
UnreadMark: unreadMark,
ViewForumAsMessages: viewForumAsMessages,
HasScheduled: hasScheduled,
Pts: ch.Pts,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID},
FolderID: folderID,
TopMessage: topID,
TopMessageDate: topDate,
HistoryClearAnchorID: historyClearAnchorID,
HistoryClearAnchorDate: historyClearAnchorDate,
ReadInboxMaxID: readInbox,
ReadOutboxMaxID: readOutbox,
UnreadCount: unreadCount,
UnreadMentions: unreadMentions,
UnreadReactions: unreadReactions,
Pinned: pinned,
PinnedOrder: pinnedOrder,
UnreadMark: unreadMark,
ViewForumAsMessages: viewForumAsMessages,
HasScheduled: hasScheduled,
Pts: ch.Pts,
}
var defaultSendAs *domain.Peer
if defaultSendAsType.Valid && defaultSendAsID.Valid && defaultSendAsID.Int64 != 0 {
@ -1236,41 +1327,45 @@ func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.
func channelDialogToDialog(dialog domain.ChannelDialog, channelPts int) domain.Dialog {
return domain.Dialog{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
FolderID: dialog.FolderID,
TopMessage: dialog.TopMessageID,
TopMessageDate: dialog.TopMessageDate,
ReadInboxMaxID: dialog.ReadInboxMaxID,
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount,
UnreadMentions: dialog.UnreadMentions,
UnreadReactions: dialog.UnreadReactions,
Pinned: dialog.Pinned,
PinnedOrder: dialog.PinnedOrder,
UnreadMark: dialog.UnreadMark,
ViewForumAsMessages: dialog.ViewForumAsMessages,
HasScheduled: dialog.HasScheduled,
Pts: channelPts,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
FolderID: dialog.FolderID,
TopMessage: dialog.TopMessageID,
TopMessageDate: dialog.TopMessageDate,
HistoryClearAnchorID: dialog.HistoryClearAnchorID,
HistoryClearAnchorDate: dialog.HistoryClearAnchorDate,
ReadInboxMaxID: dialog.ReadInboxMaxID,
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount,
UnreadMentions: dialog.UnreadMentions,
UnreadReactions: dialog.UnreadReactions,
Pinned: dialog.Pinned,
PinnedOrder: dialog.PinnedOrder,
UnreadMark: dialog.UnreadMark,
ViewForumAsMessages: dialog.ViewForumAsMessages,
HasScheduled: dialog.HasScheduled,
Pts: channelPts,
}
}
func channelDialogFromDialog(userID int64, dialog domain.Dialog) domain.ChannelDialog {
return domain.ChannelDialog{
UserID: userID,
ChannelID: dialog.Peer.ID,
FolderID: dialog.FolderID,
TopMessageID: dialog.TopMessage,
TopMessageDate: dialog.TopMessageDate,
ReadInboxMaxID: dialog.ReadInboxMaxID,
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount,
UnreadMentions: dialog.UnreadMentions,
UnreadReactions: dialog.UnreadReactions,
Pinned: dialog.Pinned,
PinnedOrder: dialog.PinnedOrder,
UnreadMark: dialog.UnreadMark,
ViewForumAsMessages: dialog.ViewForumAsMessages,
HasScheduled: dialog.HasScheduled,
UserID: userID,
ChannelID: dialog.Peer.ID,
FolderID: dialog.FolderID,
TopMessageID: dialog.TopMessage,
TopMessageDate: dialog.TopMessageDate,
HistoryClearAnchorID: dialog.HistoryClearAnchorID,
HistoryClearAnchorDate: dialog.HistoryClearAnchorDate,
ReadInboxMaxID: dialog.ReadInboxMaxID,
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount,
UnreadMentions: dialog.UnreadMentions,
UnreadReactions: dialog.UnreadReactions,
Pinned: dialog.Pinned,
PinnedOrder: dialog.PinnedOrder,
UnreadMark: dialog.UnreadMark,
ViewForumAsMessages: dialog.ViewForumAsMessages,
HasScheduled: dialog.HasScheduled,
}
}
@ -1392,14 +1487,23 @@ func channelFolderPeerIDs(primary []domain.DialogFolderPeer, rest ...[]domain.Di
func previewChannelDialog(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog {
topMessageID := channel.TopMessageID
if topMessageID <= member.AvailableMinID {
topMessageID = 0
topMessageID = member.HistoryClearAnchorID
if topMessageID != member.AvailableMinID {
topMessageID = 0
}
}
topMessageDate := channel.Date
if topMessageID > 0 && topMessageID == member.HistoryClearAnchorID {
topMessageDate = member.HistoryClearAnchorDate
}
return domain.ChannelDialog{
UserID: userID,
ChannelID: channel.ID,
TopMessageID: topMessageID,
TopMessageDate: channel.Date,
ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID),
ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID),
UserID: userID,
ChannelID: channel.ID,
TopMessageID: topMessageID,
TopMessageDate: topMessageDate,
HistoryClearAnchorID: member.HistoryClearAnchorID,
HistoryClearAnchorDate: member.HistoryClearAnchorDate,
ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID),
ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID),
}
}

View file

@ -116,7 +116,7 @@ func TestChannelStoreDifferenceStartsAtMemberAvailableMinPts(t *testing.T) {
}
}
func TestChannelStorePublicPreviewDifferenceSkipsNonMemberMessages(t *testing.T) {
func TestChannelStorePublicPreviewDifferenceReplaysVisibleMessages(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
@ -183,12 +183,57 @@ func TestChannelStorePublicPreviewDifferenceSkipsNonMemberMessages(t *testing.T)
if err != nil {
t.Fatalf("list public preview difference: %v", err)
}
if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.Events) != 0 || len(diff.NewMessages) != 0 || len(diff.OtherUpdates) != 0 {
t.Fatalf("preview diff = %+v, want empty public preview difference at current pts", diff)
if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.Events) != 1 || len(diff.NewMessages) != 1 || len(diff.OtherUpdates) != 0 {
t.Fatalf("preview diff = %+v, want one durable public preview message at pts %d", diff, sent.Event.Pts)
}
if diff.NewMessages[0].ID != sent.Message.ID || diff.NewMessages[0].Body != sent.Message.Body {
t.Fatalf("preview diff message = %+v, want sent message %+v", diff.NewMessages[0], sent.Message)
}
if diff.Dialog.UnreadCount != 0 || diff.Dialog.ReadInboxMaxID < sent.Message.ID {
t.Fatalf("preview diff dialog = %+v, want read-only public preview dialog", diff.Dialog)
}
edited, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{
UserID: owner.ID, ChannelID: channelID, ID: sent.Message.ID,
Message: "public preview edited", EditDate: 1700000372,
})
if err != nil {
t.Fatalf("edit public preview message: %v", err)
}
pinned, err := channels.UpdatePinnedMessage(ctx, domain.UpdateChannelPinnedMessageRequest{
UserID: owner.ID, ChannelID: channelID, MessageID: sent.Message.ID,
Pinned: true, Date: 1700000373,
})
if err != nil {
t.Fatalf("pin public preview message: %v", err)
}
deleted, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{
UserID: owner.ID, ChannelID: channelID, IDs: []int{sent.Message.ID}, Date: 1700000374,
})
if err != nil {
t.Fatalf("delete public preview message: %v", err)
}
mutations, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: viewer.ID, ChannelID: channelID, Pts: sent.Event.Pts, Limit: 10,
})
if err != nil {
t.Fatalf("list public preview mutations: %v", err)
}
if !mutations.Final || mutations.Pts != deleted.Event.Pts || len(mutations.NewMessages) != 0 || len(mutations.OtherUpdates) != 3 {
t.Fatalf("public preview mutations = %+v, want edit/pin/delete through pts %d", mutations, deleted.Event.Pts)
}
wantTypes := []domain.ChannelUpdateEventType{
domain.ChannelUpdateEditMessage,
domain.ChannelUpdatePinnedMessages,
domain.ChannelUpdateDeleteMessages,
}
for i, want := range wantTypes {
if mutations.OtherUpdates[i].Type != want {
t.Fatalf("public preview mutation[%d] = %+v, want %s", i, mutations.OtherUpdates[i], want)
}
}
if edited.Event.Pts >= pinned.Event.Pts || pinned.Event.Pts >= deleted.Event.Pts {
t.Fatalf("public preview mutation pts = edit %d pin %d delete %d, want strictly increasing", edited.Event.Pts, pinned.Event.Pts, deleted.Event.Pts)
}
}
func TestChannelStoreDifferenceUsesDurableMessageSnapshots(t *testing.T) {

View file

@ -31,7 +31,10 @@ func (s *ChannelStore) SaveChannelDefaultSendAs(ctx context.Context, req domain.
}
topMessageID := channel.TopMessageID
if topMessageID <= member.AvailableMinID {
topMessageID = 0
topMessageID = member.HistoryClearAnchorID
if topMessageID != member.AvailableMinID {
topMessageID = 0
}
}
if _, err := s.db.Exec(ctx, `
INSERT INTO channel_dialogs (
@ -150,11 +153,28 @@ func (s *ChannelStore) SearchPublicChannels(ctx context.Context, viewerUserID in
queryPrefix := escapeLike(queryLower) + "%"
queryLike := "%" + escapeLike(queryLower) + "%"
rows, err := s.db.Query(ctx, `
WITH username_matches AS (
SELECT
peer_id,
MIN(CASE
WHEN username_lower = $2 THEN 0
ELSE 1
END) AS rank
FROM peer_usernames
WHERE peer_type = 'channel'
AND active
AND collectible_id IS NOT NULL
AND (
username_lower = $2
OR username_lower LIKE $3 ESCAPE '\'
)
GROUP BY peer_id
)
SELECT `+channelColumns+`
FROM channels c
LEFT JOIN username_matches um ON um.peer_id = c.id
WHERE NOT c.deleted
AND (c.broadcast OR c.megagroup)
AND COALESCE(c.username, '') <> ''
AND NOT EXISTS (
SELECT 1
FROM channel_members m
@ -163,15 +183,16 @@ WHERE NOT c.deleted
AND m.status = 'active'
)
AND (
lower(c.username) = $2
um.peer_id IS NOT NULL
OR lower(c.username) = $2
OR lower(c.username) LIKE $3 ESCAPE '\'
OR lower(c.title) LIKE $3 ESCAPE '\'
OR lower(c.username) LIKE $4 ESCAPE '\'
OR lower(c.title) LIKE $4 ESCAPE '\'
)
ORDER BY CASE
WHEN lower(c.username) = $2 THEN 0
WHEN lower(c.username) LIKE $3 ESCAPE '\' THEN 1
WHEN um.rank = 0 OR lower(c.username) = $2 THEN 0
WHEN um.rank = 1 OR lower(c.username) LIKE $3 ESCAPE '\' THEN 1
WHEN lower(c.username) LIKE $4 ESCAPE '\' THEN 2
WHEN lower(c.title) LIKE $3 ESCAPE '\' THEN 3
ELSE 4
@ -375,15 +396,59 @@ LIMIT $4`, userID, sinceDate, afterChannelID, limit)
return nil, fmt.Errorf("list dirty active channels for user: %w", err)
}
defer rows.Close()
out := make([]domain.DirtyChannel, 0, limit)
byChannelID := make(map[int64]domain.DirtyChannel, limit*2)
for rows.Next() {
var item domain.DirtyChannel
if err := rows.Scan(&item.ChannelID, &item.Pts); err != nil {
return nil, err
}
item.ChannelUpdatesDirty = true
byChannelID[item.ChannelID] = item
}
if err := rows.Err(); err != nil {
return nil, err
}
clearRows, err := s.db.Query(ctx, `
SELECT i.channel_id, c.pts, i.available_min_id, i.history_clear_updated_at
FROM user_channel_member_index i
JOIN channels c ON c.id = i.channel_id AND NOT c.deleted
WHERE i.user_id = $1
AND i.status = 'active'
AND NOT i.deleted
AND i.channel_id > $3
AND i.history_clear_anchor_id > 0
AND i.history_clear_anchor_id = i.available_min_id
AND i.history_clear_updated_at >= $2
ORDER BY i.channel_id ASC
LIMIT $4`, userID, sinceDate, afterChannelID, limit)
if err != nil {
return nil, fmt.Errorf("list owner-local channel history clears for user: %w", err)
}
defer clearRows.Close()
for clearRows.Next() {
var item domain.DirtyChannel
if err := clearRows.Scan(&item.ChannelID, &item.Pts, &item.AvailableMinID, &item.HistoryClearDate); err != nil {
return nil, err
}
if existing, ok := byChannelID[item.ChannelID]; ok {
item.ChannelUpdatesDirty = existing.ChannelUpdatesDirty
}
byChannelID[item.ChannelID] = item
}
if err := clearRows.Err(); err != nil {
return nil, err
}
out := make([]domain.DirtyChannel, 0, len(byChannelID))
for _, item := range byChannelID {
out = append(out, item)
}
return out, rows.Err()
sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID })
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
type rowScanner interface {
@ -421,7 +486,12 @@ func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX,
return ch, syntheticMonoforumUserMember(ch, viewerUserID), true, nil
}
}
if !publicPreviewableChannel(ch) {
publicUsernameIDs, err := activeCollectibleUsernamePeerIDs(ctx, db, peerUsernameTypeChannel, []int64{ch.ID})
if err != nil {
return domain.Channel{}, domain.ChannelMember{}, false, err
}
_, hasActiveUsername := publicUsernameIDs[ch.ID]
if !publicPreviewableChannel(ch, hasActiveUsername) {
return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate
}
member, err = s.getPublicPreviewMember(ctx, db, viewerUserID, ch)
@ -431,6 +501,19 @@ func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX,
return ch, member, true, nil
}
// channelMessageVisibleToViewer applies the message-level half of synthetic monoforum access.
// Subscribers do not have channel_members rows and may only address saved_peer=self; a synthetic
// manager view may address every subscriber sub-dialog.
func channelMessageVisibleToViewer(channel domain.Channel, member domain.ChannelMember, viewerUserID int64, msg domain.ChannelMessage) bool {
if !channel.Monoforum {
return true
}
if member.CanManageDirectMessages() {
return true
}
return msg.SavedPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: viewerUserID})
}
func getChannelByID(ctx context.Context, db sqlcgen.DBTX, channelID int64) (domain.Channel, error) {
ch, err := scanChannel(db.QueryRow(ctx, `SELECT `+channelColumns+` FROM channels c WHERE c.id = $1 AND NOT c.deleted`, channelID))
if errors.Is(err, pgx.ErrNoRows) {

View file

@ -207,6 +207,45 @@ func TestReadModelChangeListenerInvalidatesPrivateMediaCountCache(t *testing.T)
}
}
func TestReadModelChangeListenerInvalidatesAccountSettingsCache(t *testing.T) {
settings := &fakeAccountSettingsReadModelCache{}
listener := NewReadModelChangeListener("", ReadModelCacheSet{
AccountSettings: settings,
}, nil)
listener.handlePayload(`{"model":"account_settings","owner_user_id":100,"peer_type":"user","peer_id":100,"version":2}`)
if len(settings.invalidated) != 1 || settings.invalidated[0] != 100 {
t.Fatalf("account_settings invalidations = %v, want [100]", settings.invalidated)
}
if len(settings.warmed) != 1 || settings.warmed[0] != 100 {
t.Fatalf("account_settings warmups = %v, want [100]", settings.warmed)
}
listener.flush("test")
if settings.flushes != 1 {
t.Fatalf("account_settings flushes = %d, want 1", settings.flushes)
}
}
type fakeAccountSettingsReadModelCache struct {
invalidated []int64
warmed []int64
flushes int
}
func (f *fakeAccountSettingsReadModelCache) InvalidateAccountSettingsReadModel(userID int64) {
f.invalidated = append(f.invalidated, userID)
}
func (f *fakeAccountSettingsReadModelCache) FlushAccountSettingsReadModel() {
f.flushes++
}
func (f *fakeAccountSettingsReadModelCache) WarmAccountSettingsReadModel(_ context.Context, userID int64) error {
f.warmed = append(f.warmed, userID)
return nil
}
// TestReadModelChangeListenerBotFullFlushesChannelFullBots 回归: bot 改资料(bot_full 事件,
// 迁移 0013)须 flush channelFullBotInfoCache(否则群信息页 bot 简介/命令跨实例陈旧至 TTL);
// 普通用户的 user_base 事件不得 flush(否则该缓存形同虚设)。
@ -419,6 +458,7 @@ func (f *fakeDialogReadModelCache) flushCount() int {
type fakePrivacyReadModelCache struct {
mu sync.Mutex
ids []int64
warmed []int64
flushes int
}
@ -428,6 +468,13 @@ func (f *fakePrivacyReadModelCache) InvalidateOwners(ids ...int64) {
f.ids = append(f.ids, ids...)
}
func (f *fakePrivacyReadModelCache) WarmOwners(_ context.Context, ids ...int64) error {
f.mu.Lock()
defer f.mu.Unlock()
f.warmed = append(f.warmed, ids...)
return nil
}
func (f *fakePrivacyReadModelCache) FlushReadModelCache() {
f.mu.Lock()
defer f.mu.Unlock()
@ -440,6 +487,12 @@ func (f *fakePrivacyReadModelCache) idsSnapshot() []int64 {
return append([]int64(nil), f.ids...)
}
func (f *fakePrivacyReadModelCache) warmedSnapshot() []int64 {
f.mu.Lock()
defer f.mu.Unlock()
return append([]int64(nil), f.warmed...)
}
func (f *fakePrivacyReadModelCache) flushCount() int {
f.mu.Lock()
defer f.mu.Unlock()
@ -509,6 +562,9 @@ func TestReadModelChangeListenerInvalidatesAccountCaches(t *testing.T) {
if len(privacy.ids) != 1 || privacy.ids[0] != 21 {
t.Fatalf("privacy invalidations = %v, want [21]", privacy.ids)
}
if warmed := privacy.warmedSnapshot(); len(warmed) != 1 || warmed[0] != 21 {
t.Fatalf("privacy warms = %v, want [21]", warmed)
}
listener.handlePayload(`{"model":"dialog_light","owner_user_id":22,"peer_type":"user","peer_id":32,"version":4}`)
if len(dialogs.owners) != 1 || dialogs.owners[0] != 22 || dialogs.keys[0] != (domain.Peer{Type: domain.PeerTypeUser, ID: 32}) {

View file

@ -189,6 +189,7 @@ func getChannelMemberByID(ctx context.Context, db sqlcgen.DBTX, channelID, userI
row := db.QueryRow(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at,
admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts,
history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
FROM channel_members
WHERE channel_id = $1 AND user_id = $2`, channelID, userID)
@ -214,8 +215,9 @@ func upsertChannelMemberTx(ctx context.Context, tx pgx.Tx, channel domain.Channe
if _, err := tx.Exec(ctx, `
INSERT INTO channel_members (
channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights, banned_rights,
rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
rank, available_min_id, available_min_pts, history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
ON CONFLICT (channel_id, user_id) DO UPDATE SET
inviter_user_id = EXCLUDED.inviter_user_id,
role = EXCLUDED.role,
@ -231,7 +233,8 @@ ON CONFLICT (channel_id, user_id) DO UPDATE SET
updated_at = now()`,
member.ChannelID, member.UserID, member.InviterUserID, string(member.Role), string(member.Status),
member.JoinedAt, member.LeftAt, adminRights, bannedRights, member.Rank, member.AvailableMinID,
member.AvailableMinPts, member.ReadInboxMaxID, member.ReadOutboxMaxID, member.UnreadMark, member.SlowmodeLastSendDate); err != nil {
member.AvailableMinPts, member.HistoryClearAnchorID, member.HistoryClearAnchorDate,
member.ReadInboxMaxID, member.ReadOutboxMaxID, member.UnreadMark, member.SlowmodeLastSendDate); err != nil {
return fmt.Errorf("upsert channel member: %w", err)
}
return upsertUserChannelMemberIndexTx(ctx, tx, channel, member)
@ -316,7 +319,8 @@ func scanChannelWithMember(row rowScanner) (domain.Channel, domain.ChannelMember
dest := append(channelScanDest(&ch, &defaultRights, &reactionPolicy, &wallpaper),
&member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status,
&member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank,
&member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
&member.AvailableMinID, &member.AvailableMinPts, &member.HistoryClearAnchorID, &member.HistoryClearAnchorDate,
&member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
)
if err := row.Scan(dest...); err != nil {
return domain.Channel{}, domain.ChannelMember{}, err
@ -351,7 +355,8 @@ func scanChannelMember(row rowScanner) (domain.ChannelMember, error) {
if err := row.Scan(
&member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status,
&member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank,
&member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
&member.AvailableMinID, &member.AvailableMinPts, &member.HistoryClearAnchorID, &member.HistoryClearAnchorDate,
&member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
); err != nil {
return domain.ChannelMember{}, err
}
@ -370,7 +375,8 @@ func scanChannelMemberWithCount(row rowScanner) (domain.ChannelMember, int, erro
if err := row.Scan(
&member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status,
&member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank,
&member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
&member.AvailableMinID, &member.AvailableMinPts, &member.HistoryClearAnchorID, &member.HistoryClearAnchorDate,
&member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
&count,
); err != nil {
return domain.ChannelMember{}, 0, err
@ -471,6 +477,8 @@ func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.Chan
}
member.AvailableMinID = 0
member.AvailableMinPts = 0
member.HistoryClearAnchorID = 0
member.HistoryClearAnchorDate = 0
member.ReadInboxMaxID = mono.TopMessageID
member.ReadOutboxMaxID = mono.TopMessageID
member.UnreadMark = false

View file

@ -2,6 +2,7 @@ package postgres
import (
"context"
"strings"
"testing"
"telesrv/internal/domain"
@ -123,7 +124,12 @@ SELECT peer_id
FROM peer_usernames
WHERE username_lower = $1 AND peer_type = 'channel'
`, publicUsername)
requirePlanContains(t, usernameLookupPlan, "peer_usernames_pkey")
// 0150 adds peer_usernames_peer_order_idx, which covers this lookup as an
// index-only scan; either index is an acceptable plan, a partition scan is not.
if !strings.Contains(usernameLookupPlan, "peer_usernames_pkey") &&
!strings.Contains(usernameLookupPlan, "peer_usernames_peer_order_idx") {
t.Fatalf("username lookup plan = %s, want a peer_usernames index scan", usernameLookupPlan)
}
requirePlanNotMatches(t, usernameLookupPlan, `channels_p\d+`)
usernameChannelDetailPlan := explainText(t, ctx, tx, `

View file

@ -267,6 +267,7 @@ func (s *ChannelStore) futureCreatorAfterLeave(ctx context.Context, db sqlcgen.D
member, err := scanChannelMember(db.QueryRow(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at,
admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts,
history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
FROM channel_members
WHERE channel_id = $1

View file

@ -128,7 +128,8 @@ func (s *ChannelStore) GetParticipants(ctx context.Context, viewerUserID, channe
}
rows, err := s.db.Query(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text,
rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
rank, available_min_id, available_min_pts, history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
`+from+`
WHERE `+strings.Join(where, " AND ")+`
ORDER BY CASE role WHEN 'creator' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END, user_id
@ -221,7 +222,8 @@ func (s *ChannelStore) ListActiveChannelMembers(ctx context.Context, viewerUserI
}
rows, err := s.db.Query(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text,
rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
rank, available_min_id, available_min_pts, history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
FROM channel_members
WHERE channel_id = $1 AND status = 'active'
ORDER BY user_id
@ -264,6 +266,7 @@ func (s *ChannelStore) ListActiveChannelBotMembers(ctx context.Context, viewerUs
rows, err := s.db.Query(ctx, `
SELECT m.channel_id, m.user_id, m.inviter_user_id, m.role, m.status, m.joined_at, m.left_at,
m.admin_rights::text, m.banned_rights::text, m.rank, m.available_min_id, m.available_min_pts,
m.history_clear_anchor_id, m.history_clear_anchor_date,
m.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date,
COUNT(*) OVER()::int
FROM bots b
@ -360,3 +363,52 @@ ORDER BY user_id`, channelID, candidates[start:end])
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out, nil
}
func (s *ChannelStore) FilterChannelMessageAudienceIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) {
if channelID == 0 || len(userIDs) == 0 {
return nil, nil
}
candidates := uniqueChannelUserIDs(userIDs, 0)
if len(candidates) == 0 {
return nil, nil
}
out := make([]int64, 0, len(candidates))
for start := 0; start < len(candidates); start += channelMemberFilterBatch {
end := start + channelMemberFilterBatch
if end > len(candidates) {
end = len(candidates)
}
rows, err := s.db.Query(ctx, `
SELECT candidate.user_id
FROM channels c
CROSS JOIN unnest($2::bigint[]) AS candidate(user_id)
LEFT JOIN channel_members m
ON m.channel_id = c.id AND m.user_id = candidate.user_id
WHERE c.id = $1
AND NOT c.deleted
AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false)
AND COALESCE(m.status, '') NOT IN ('kicked', 'banned')
AND (
m.status = 'active'
OR (COALESCE(c.username, '') <> '' AND COALESCE(m.status, 'left') = 'left')
)
ORDER BY candidate.user_id`, channelID, candidates[start:end])
if err != nil {
return nil, fmt.Errorf("filter channel message audience: %w", err)
}
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
rows.Close()
return nil, err
}
out = append(out, userID)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
}
return out, nil
}

View file

@ -2,6 +2,7 @@ package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"sort"
@ -60,6 +61,68 @@ func (s *ChannelStore) DeleteChannelMessages(ctx context.Context, req domain.Del
return domain.DeleteChannelMessagesResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, DiscussionDeletes: cascades}, nil
}
func (s *ChannelStore) ModerationDeleteChannelMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error) {
if channelID <= 0 || len(ids) == 0 || len(ids) > domain.MaxDeleteMessageIDs {
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
}
if date == 0 {
date = nowUnix()
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.DeleteChannelMessagesResult{}, fmt.Errorf("moderation delete channel messages: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.DeleteChannelMessagesResult{}, fmt.Errorf("begin moderation delete channel messages: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
channel, err := getChannelByID(ctx, tx, channelID)
if err != nil || channel.Deleted {
if err != nil {
return domain.DeleteChannelMessagesResult{}, err
}
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
}
refs, err := s.discussionRefsForMessages(ctx, tx, channel.ID, ids)
if err != nil {
return domain.DeleteChannelMessagesResult{}, err
}
systemMember := domain.ChannelMember{
ChannelID: channel.ID, UserID: domain.OfficialSystemUserID,
Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive,
}
deleted, event, channel, err := s.deleteChannelMessagesTx(
ctx, tx, channel, systemMember, ids, domain.OfficialSystemUserID, date,
)
if err != nil {
return domain.DeleteChannelMessagesResult{}, err
}
cascades, err := s.cascadeDeleteDiscussionRootsTx(
ctx, tx, refs, deleted, domain.OfficialSystemUserID, date,
)
if err != nil {
return domain.DeleteChannelMessagesResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.DeleteChannelMessagesResult{}, fmt.Errorf("commit moderation delete channel messages: %w", err)
}
committed = true
recipients, _ := s.ListActiveChannelMemberIDs(ctx, 0, channel.ID, 0)
for i := range cascades {
cascades[i].Recipients, _ = s.ListActiveChannelMemberIDs(ctx, 0, cascades[i].Channel.ID, 0)
}
return domain.DeleteChannelMessagesResult{
Channel: channel, Event: event, DeletedIDs: deleted,
Recipients: recipients, DiscussionDeletes: cascades,
}, nil
}
// discussionRefsForMessages 取待删消息携带的讨论组转发根引用。
func (s *ChannelStore) discussionRefsForMessages(ctx context.Context, tx pgx.Tx, channelID int64, ids []int) (map[int]domain.ChannelDiscussionRef, error) {
id32, _, err := validUniqueChannelMessageIDs(ids)
@ -161,19 +224,68 @@ func (s *ChannelStore) DeleteChannelHistory(ctx context.Context, req domain.Dele
}
if !req.ForEveryone {
appliedMinID := maxInt(member.AvailableMinID, maxID)
changed := appliedMinID > member.AvailableMinID
anchorID := member.HistoryClearAnchorID
anchorDate := member.HistoryClearAnchorDate
if changed {
anchorID = appliedMinID
anchorDate = req.Date
var messageDate int
err := tx.QueryRow(ctx, `
SELECT message_date
FROM channel_messages
WHERE channel_id = $1 AND id = $2`, req.ChannelID, appliedMinID).Scan(&messageDate)
switch {
case err == nil && messageDate > 0:
anchorDate = messageDate
case errors.Is(err, pgx.ErrNoRows):
// max_id may name an already-pruned/hole id. The owner-local
// marker is still a valid monotonic boundary; retain request
// time so its dialog projection remains loadable.
case err != nil:
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("select channel clear anchor date: %w", err)
}
if anchorDate <= 0 {
anchorDate = channel.Date
}
}
topID, topDate, err := visibleChannelTopAfter(ctx, tx, req.ChannelID, appliedMinID, channel.Date)
if err != nil {
return domain.DeleteChannelHistoryResult{}, err
}
if topID == 0 && anchorID > 0 && anchorID == appliedMinID {
topID = anchorID
topDate = anchorDate
}
if _, err := tx.Exec(ctx, `
UPDATE channel_members
SET available_min_id = GREATEST(available_min_id, $3),
history_clear_anchor_id = CASE WHEN available_min_id < $3 THEN $3 ELSE history_clear_anchor_id END,
history_clear_anchor_date = CASE WHEN available_min_id < $3 THEN $4 ELSE history_clear_anchor_date END,
read_inbox_max_id = GREATEST(read_inbox_max_id, $3),
unread_mark = false,
updated_at = now()
WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, appliedMinID); err != nil {
WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, appliedMinID, anchorDate); err != nil {
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("update channel local clear member: %w", err)
}
if changed {
member.AvailableMinID = appliedMinID
member.HistoryClearAnchorID = anchorID
member.HistoryClearAnchorDate = anchorDate
if err := upsertUserChannelMemberIndexTx(ctx, tx, channel, member); err != nil {
return domain.DeleteChannelHistoryResult{}, err
}
if _, err := tx.Exec(ctx, `
UPDATE user_channel_member_index
SET available_min_id = $3,
history_clear_anchor_id = $3,
history_clear_updated_at = $4,
updated_at = now()
WHERE user_id = $1 AND channel_id = $2`,
req.UserID, req.ChannelID, appliedMinID, req.Date); err != nil {
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("update channel local clear recovery index: %w", err)
}
}
if err := deleteChannelUnreadMentionsUpToTx(ctx, tx, req.UserID, req.ChannelID, appliedMinID); err != nil {
return domain.DeleteChannelHistoryResult{}, err
}
@ -197,7 +309,17 @@ ON CONFLICT (user_id, channel_id) DO UPDATE SET
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit local clear channel history: %w", err)
}
committed = true
return domain.DeleteChannelHistoryResult{Channel: channel, AvailableMinID: appliedMinID}, nil
if s.memberCacheActive(s.db) {
s.memberCache.delete(req.ChannelID, req.UserID)
}
if s.dialogCacheActive(s.db) {
s.dialogCache.delete(req.UserID, req.ChannelID)
}
return domain.DeleteChannelHistoryResult{
Channel: channel,
AvailableMinID: appliedMinID,
AvailableMinChanged: changed,
}, nil
}
if !canDeleteAnyChannelMessage(member) {
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired

View file

@ -79,6 +79,31 @@ AND EXISTS (
baseArgs = append(baseArgs, filter.MinID)
base += fmt.Sprintf(" AND id > $%d", len(baseArgs))
}
historyClearAnchor, hasHistoryClearAnchor, err := s.channelHistoryClearAnchor(ctx, channel, member, filter)
if err != nil {
return domain.ChannelHistory{}, err
}
out := domain.ChannelHistory{Channel: channel, Self: member, Channels: extraChannels}
needExactTotal := filter.NeedTotalCount || filter.CountOnly
exactTotal := 0
if needExactTotal {
// Exact totals are opt-in. messages.search first/count-only pages and
// messages.getSearchCounters need protocol-exact Count, while ordinary
// getHistory pages must stay on the single bounded page query.
if err := s.db.QueryRow(ctx,
"SELECT count(*)::int FROM channel_messages WHERE "+base,
baseArgs...,
).Scan(&exactTotal); err != nil {
return domain.ChannelHistory{}, fmt.Errorf("count channel history: %w", err)
}
if hasHistoryClearAnchor {
exactTotal++
}
out.Count = exactTotal
}
if filter.CountOnly {
return out, nil
}
scanList := func(sql string, queryArgs []any) ([]domain.ChannelMessage, error) {
rows, err := s.db.Query(ctx, sql, queryArgs...)
if err != nil {
@ -102,7 +127,6 @@ AND EXISTS (
// store 层二次钳制 add_offset 到 [-100,100](与私聊 ListMessagesByUser 对齐):
// 即便某个 caller 漏在 RPC 层钳制,也不会把客户端巨大值变成大 SQL OFFSET 跳扫。
addOffset := domain.ClampMessageHistoryAddOffset(filter.AddOffset)
out := domain.ChannelHistory{Channel: channel, Self: member, Channels: extraChannels}
hasMoreOlder := false
// 锚点条件:offset_date 优先按日期、否则按消息 id(对齐私聊);
// 二者皆空时向更新方向退化为空、向更旧方向退化为全部(取最新)。
@ -117,6 +141,18 @@ AND EXISTS (
}
return "false"
}
anchorMatchesForward := func() bool {
if !hasHistoryClearAnchor {
return false
}
if filter.OffsetDate > 0 {
return historyClearAnchor.Date >= filter.OffsetDate
}
if filter.OffsetID > 0 {
return historyClearAnchor.ID > filter.OffsetID
}
return false
}
aroundOlderCond := func(args *[]any) string {
if filter.OffsetDate > 0 {
*args = append(*args, filter.OffsetDate)
@ -128,6 +164,30 @@ AND EXISTS (
}
return "true"
}
anchorMatchesAroundOlder := func() bool {
if !hasHistoryClearAnchor {
return false
}
if filter.OffsetDate > 0 {
return historyClearAnchor.Date < filter.OffsetDate
}
if filter.OffsetID > 0 {
return historyClearAnchor.ID <= filter.OffsetID
}
return true
}
anchorMatchesBackward := func() bool {
if !hasHistoryClearAnchor {
return false
}
if filter.OffsetDate > 0 {
return historyClearAnchor.Date < filter.OffsetDate
}
if filter.OffsetID > 0 {
return historyClearAnchor.ID < filter.OffsetID
}
return true
}
switch {
case addOffset < 0 && addOffset+limit > 0:
// around:以锚点为中心,向更新取 -add_offset 条 + 向更旧(含锚点)取 limit+add_offset 条
@ -140,6 +200,12 @@ AND EXISTS (
if err != nil {
return domain.ChannelHistory{}, err
}
if anchorMatchesForward() {
newer = append([]domain.ChannelMessage{historyClearAnchor}, newer...)
if len(newer) > fwdLimit {
newer = newer[:fwdLimit]
}
}
bwdArgs := append([]any{}, baseArgs...)
bwdWhere := aroundOlderCond(&bwdArgs)
bwdArgs = append(bwdArgs, bwdLimit+1)
@ -147,6 +213,9 @@ AND EXISTS (
if err != nil {
return domain.ChannelHistory{}, err
}
if anchorMatchesAroundOlder() {
older = append(older, historyClearAnchor)
}
if len(older) > bwdLimit {
older = older[:bwdLimit]
hasMoreOlder = true
@ -164,6 +233,9 @@ AND EXISTS (
if err != nil {
return domain.ChannelHistory{}, err
}
if anchorMatchesForward() {
newer = append([]domain.ChannelMessage{historyClearAnchor}, newer...)
}
if len(newer) > limit {
newer = newer[:limit]
}
@ -181,27 +253,37 @@ AND EXISTS (
args = append(args, filter.OffsetID)
where += fmt.Sprintf(" AND id < $%d", len(args))
}
args = append(args, limit+1)
// Fetch the bounded add_offset window and slice it in memory. This keeps
// the shared-history branch on its ordered (channel_id,id) seek index;
// the owner-local anchor is one separate PK lookup and never adds an OR
// that would force BitmapOr + Sort for large channels.
args = append(args, addOffset+limit+1)
limIdx := len(args)
sql := "SELECT " + channelMessageColumns + " FROM channel_messages WHERE " + where + " ORDER BY id DESC"
if addOffset > 0 {
args = append(args, addOffset)
sql += fmt.Sprintf(" OFFSET $%d", len(args))
}
sql += fmt.Sprintf(" LIMIT $%d", limIdx)
sql := "SELECT " + channelMessageColumns + " FROM channel_messages WHERE " + where +
fmt.Sprintf(" ORDER BY id DESC LIMIT $%d", limIdx)
older, err := scanList(sql, args)
if err != nil {
return domain.ChannelHistory{}, err
}
if anchorMatchesBackward() {
older = append(older, historyClearAnchor)
}
if addOffset >= len(older) {
older = nil
} else if addOffset > 0 {
older = older[addOffset:]
}
if len(older) > limit {
older = older[:limit]
hasMoreOlder = true
}
out.Messages = older
}
out.Count = len(out.Messages)
if hasMoreOlder {
out.Count = len(out.Messages) + 1
if !needExactTotal {
out.Count = len(out.Messages)
if hasMoreOlder {
out.Count = len(out.Messages) + 1
}
}
if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, out.Messages); err != nil {
return domain.ChannelHistory{}, err
@ -209,9 +291,63 @@ AND EXISTS (
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil {
return domain.ChannelHistory{}, err
}
if hasHistoryClearAnchor {
for i := range out.Messages {
if out.Messages[i].ID == historyClearAnchor.ID {
out.Messages[i] = domain.ProjectChannelHistoryClearMessage(
out.Messages[i],
channel.ID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
)
}
}
}
return out, nil
}
func (s *ChannelStore) channelHistoryClearAnchor(
ctx context.Context,
channel domain.Channel,
member domain.ChannelMember,
filter domain.ChannelHistoryFilter,
) (domain.ChannelMessage, bool, error) {
if !filter.IncludeHistoryClearAnchor ||
member.HistoryClearAnchorID <= 0 ||
member.HistoryClearAnchorID != member.AvailableMinID ||
filter.PinnedOnly ||
filter.MusicOnly ||
filter.Query != "" {
return domain.ChannelMessage{}, false, nil
}
source, err := s.getChannelMessage(ctx, s.db, channel.ID, member.HistoryClearAnchorID)
if err != nil && !errors.Is(err, domain.ErrMessageIDInvalid) {
return domain.ChannelMessage{}, false, fmt.Errorf("load channel history-clear anchor: %w", err)
}
anchor := domain.ProjectChannelHistoryClearMessage(
source,
channel.ID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
)
if filter.SenderUserID != 0 && anchor.SenderUserID != filter.SenderUserID {
return domain.ChannelMessage{}, false, nil
}
if filter.MinDate > 0 && anchor.Date <= filter.MinDate {
return domain.ChannelMessage{}, false, nil
}
if filter.MaxDate > 0 && anchor.Date >= filter.MaxDate {
return domain.ChannelMessage{}, false, nil
}
if filter.MaxID > 0 && anchor.ID > filter.MaxID {
return domain.ChannelMessage{}, false, nil
}
if filter.MinID > 0 && anchor.ID <= filter.MinID {
return domain.ChannelMessage{}, false, nil
}
return anchor, true, nil
}
func (s *ChannelStore) SearchJoinedMessages(ctx context.Context, viewerUserID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) {
query := strings.TrimSpace(req.Query)
if viewerUserID == 0 || (query == "" && !req.MusicOnly) {
@ -371,8 +507,19 @@ func (s *ChannelStore) getChannelMessagesForMember(ctx context.Context, viewerUs
// 执行不变。注意:这种"OR 哨兵"只对【非排序锚点】的残余过滤安全;ListChannelHistory
// 的方向/anchor 条件若同样哨兵化会让规划器无法用索引顺序做 LIMIT、退化为全表扫+排序
// (实测 0.06ms→23ms),故那里【刻意保留】动态 SQL。
args := []any{channel.ID, id32, member.AvailableMinID}
where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted AND ($3 <= 0 OR id > $3)"
anchorID := 0
if member.HistoryClearAnchorID > 0 && member.HistoryClearAnchorID == member.AvailableMinID {
anchorID = member.HistoryClearAnchorID
}
args := []any{channel.ID, id32, member.AvailableMinID, anchorID}
where := `channel_id = $1
AND id = ANY($2::int[])
AND (NOT deleted OR ($4 > 0 AND id = $4))
AND (($3 <= 0 OR id > $3) OR ($4 > 0 AND id = $4))`
if channel.Monoforum && !member.CanManageDirectMessages() {
args = append(args, string(domain.PeerTypeUser), viewerUserID)
where += fmt.Sprintf("\nAND saved_peer_type = $%d AND saved_peer_id = $%d", len(args)-1, len(args))
}
rows, err := s.db.Query(ctx, `
SELECT `+channelMessageColumns+`
FROM channel_messages
@ -400,6 +547,36 @@ ORDER BY id DESC`, args...)
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil {
return domain.ChannelHistory{}, err
}
if anchorID > 0 {
anchorFound := false
for i := range out.Messages {
if out.Messages[i].ID != anchorID {
continue
}
out.Messages[i] = domain.ProjectChannelHistoryClearMessage(
out.Messages[i],
channel.ID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
)
anchorFound = true
}
if !anchorFound {
for _, id := range ids {
if id != anchorID {
continue
}
out.Messages = append(out.Messages, domain.ProjectChannelHistoryClearMessage(
domain.ChannelMessage{},
channel.ID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
))
out.Count = len(out.Messages)
break
}
}
}
return out, nil
}
@ -737,7 +914,7 @@ WHERE (
}
func (s *ChannelStore) readChannelHistoryOnce(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) {
channel, _, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID)
channel, _, readOnly, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID)
if err != nil {
return domain.ReadChannelHistoryResult{}, err
}
@ -745,6 +922,15 @@ func (s *ChannelStore) readChannelHistoryOnce(ctx context.Context, req domain.Re
if maxID <= 0 || maxID > channel.TopMessageID {
maxID = channel.TopMessageID
}
if readOnly {
return domain.ReadChannelHistoryResult{
ChannelID: req.ChannelID,
MaxID: maxID,
ReadOnly: true,
Pts: channel.Pts,
Forum: channel.Forum,
}, nil
}
previous, unreadMark, err := s.channelReadHistoryState(ctx, req.ChannelID, req.UserID)
if err != nil {
return domain.ReadChannelHistoryResult{}, fmt.Errorf("read channel member state: %w", err)

View file

@ -189,6 +189,17 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberR
t.Fatalf("read participants = %+v, want friend read date", readers.Participants)
}
var channelPtsBeforeClear, channelEventsBeforeClear int
if err := pool.QueryRow(ctx, `
SELECT c.pts, (
SELECT count(*)::int
FROM channel_update_events e
WHERE e.channel_id = c.id
)
FROM channels c
WHERE c.id = $1`, channelID).Scan(&channelPtsBeforeClear, &channelEventsBeforeClear); err != nil {
t.Fatalf("query channel state before local clear: %v", err)
}
cleared, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: friend.ID,
ChannelID: channelID,
@ -201,6 +212,70 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberR
if cleared.AvailableMinID != sent.Message.ID {
t.Fatalf("local clear available_min_id = %d, want %d", cleared.AvailableMinID, sent.Message.ID)
}
if !cleared.AvailableMinChanged {
t.Fatal("local clear did not report an advanced owner-local boundary")
}
var channelPtsAfterClear, channelEventsAfterClear int
if err := pool.QueryRow(ctx, `
SELECT c.pts, (
SELECT count(*)::int
FROM channel_update_events e
WHERE e.channel_id = c.id
)
FROM channels c
WHERE c.id = $1`, channelID).Scan(&channelPtsAfterClear, &channelEventsAfterClear); err != nil {
t.Fatalf("query channel state after local clear: %v", err)
}
if channelPtsAfterClear != channelPtsBeforeClear || channelEventsAfterClear != channelEventsBeforeClear {
t.Fatalf("local clear changed channel sequence: pts %d->%d events %d->%d",
channelPtsBeforeClear, channelPtsAfterClear, channelEventsBeforeClear, channelEventsAfterClear)
}
var recoveryMinID, recoveryAnchorID, recoveryDate int
if err := pool.QueryRow(ctx, `
SELECT available_min_id, history_clear_anchor_id, history_clear_updated_at
FROM user_channel_member_index
WHERE user_id = $1 AND channel_id = $2`, friend.ID, channelID).Scan(
&recoveryMinID, &recoveryAnchorID, &recoveryDate,
); err != nil {
t.Fatalf("query owner-local clear recovery index: %v", err)
}
if recoveryMinID != sent.Message.ID || recoveryAnchorID != sent.Message.ID || recoveryDate != 1700000302 {
t.Fatalf("recovery index = min %d anchor %d date %d, want %d/%d/1700000302",
recoveryMinID, recoveryAnchorID, recoveryDate, sent.Message.ID, sent.Message.ID)
}
dirtyAfterClear, err := channels.ListDirtyActiveChannelsForUser(ctx, friend.ID, 1700000302, 0, 10)
if err != nil {
t.Fatalf("list dirty channels after local clear: %v", err)
}
if len(dirtyAfterClear) != 1 ||
dirtyAfterClear[0].ChannelID != channelID ||
dirtyAfterClear[0].AvailableMinID != sent.Message.ID ||
dirtyAfterClear[0].HistoryClearDate != 1700000302 {
t.Fatalf("dirty channel recovery = %+v, want channel %d boundary %d", dirtyAfterClear, channelID, sent.Message.ID)
}
planTx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin history-clear recovery plan transaction: %v", err)
}
defer func() { _ = planTx.Rollback(ctx) }()
if _, err := planTx.Exec(ctx, "SET LOCAL enable_seqscan = off"); err != nil {
t.Fatalf("disable seqscan for history-clear recovery plan: %v", err)
}
clearPlan := explainText(t, ctx, planTx, `
SELECT i.channel_id, c.pts, i.available_min_id, i.history_clear_updated_at
FROM user_channel_member_index i
JOIN channels c ON c.id = i.channel_id AND NOT c.deleted
WHERE i.user_id = $1
AND i.status = 'active'
AND NOT i.deleted
AND i.channel_id > $3
AND i.history_clear_anchor_id > 0
AND i.history_clear_anchor_id = i.available_min_id
AND i.history_clear_updated_at >= $2
ORDER BY i.channel_id ASC
LIMIT $4`, friend.ID, 1700000302, int64(0), 10)
requirePlanContains(t, clearPlan, "user_channel_member_index_history_clear_idx")
_ = planTx.Rollback(ctx)
staleClear, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: friend.ID,
ChannelID: channelID,
@ -213,12 +288,60 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberR
if staleClear.AvailableMinID != sent.Message.ID {
t.Fatalf("stale local clear available_min_id = %d, want monotonic %d", staleClear.AvailableMinID, sent.Message.ID)
}
if staleClear.AvailableMinChanged {
t.Fatal("stale local clear unexpectedly replaced the owner-local anchor")
}
if err := pool.QueryRow(ctx, `
SELECT history_clear_updated_at
FROM user_channel_member_index
WHERE user_id = $1 AND channel_id = $2`, friend.ID, channelID).Scan(&recoveryDate); err != nil {
t.Fatalf("query recovery timestamp after stale clear: %v", err)
}
if recoveryDate != 1700000302 {
t.Fatalf("stale clear recovery date = %d, want unchanged 1700000302", recoveryDate)
}
afterClear, err := channels.GetChannel(ctx, friend.ID, channelID)
if err != nil {
t.Fatalf("get channel after clear: %v", err)
}
if afterClear.Dialog.TopMessageID != 0 {
t.Fatalf("dialog after clear = %+v, want no visible top", afterClear.Dialog)
if afterClear.Dialog.TopMessageID != sent.Message.ID ||
afterClear.Dialog.HistoryClearAnchorID != sent.Message.ID ||
afterClear.Dialog.UnreadCount != 0 {
t.Fatalf("dialog after clear = %+v, want owner-local anchored top %d", afterClear.Dialog, sent.Message.ID)
}
dialogsAfterClear, err := channels.GetChannelDialogs(ctx, friend.ID, []int64{channelID})
if err != nil {
t.Fatalf("get channel dialogs after clear: %v", err)
}
if len(dialogsAfterClear.Dialogs) != 1 ||
len(dialogsAfterClear.Messages) != 1 ||
!domain.IsChannelHistoryClearMessage(dialogsAfterClear.Messages[0]) ||
dialogsAfterClear.Messages[0].ID != sent.Message.ID ||
dialogsAfterClear.Messages[0].Body != "" {
t.Fatalf("dialog projection after clear = dialogs=%+v messages=%+v", dialogsAfterClear.Dialogs, dialogsAfterClear.Messages)
}
projectedHistory, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
ChannelID: channelID,
Limit: 10,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("project history-clear anchor: %v", err)
}
if len(projectedHistory.Messages) != 1 ||
!domain.IsChannelHistoryClearMessage(projectedHistory.Messages[0]) ||
projectedHistory.Messages[0].ID != sent.Message.ID {
t.Fatalf("projected history after clear = %+v, want anchor %d", projectedHistory.Messages, sent.Message.ID)
}
ownerDialogsAfterClear, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID})
if err != nil {
t.Fatalf("get unaffected owner dialog after friend clear: %v", err)
}
if len(ownerDialogsAfterClear.Messages) != 1 ||
ownerDialogsAfterClear.Messages[0].ID != sent.Message.ID ||
ownerDialogsAfterClear.Messages[0].Body != "first visible channel text" ||
ownerDialogsAfterClear.Messages[0].Action != nil {
t.Fatalf("friend clear changed shared owner projection: %+v", ownerDialogsAfterClear.Messages)
}
next, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
@ -238,6 +361,59 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberR
if afterNext.Dialog.TopMessageID != next.Message.ID || afterNext.Dialog.UnreadCount != 1 {
t.Fatalf("dialog after next = %+v, want top %d unread 1", afterNext.Dialog, next.Message.ID)
}
firstPage, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
ChannelID: channelID,
Limit: 1,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("list first page after next message: %v", err)
}
if len(firstPage.Messages) != 1 ||
firstPage.Messages[0].ID != next.Message.ID ||
firstPage.Count != 2 {
t.Fatalf("first page after next = %+v count=%d, want next message and bounded has-more count", firstPage.Messages, firstPage.Count)
}
offsetPage, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
ChannelID: channelID,
AddOffset: 1,
Limit: 1,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("list add-offset page after next message: %v", err)
}
if len(offsetPage.Messages) != 1 ||
offsetPage.Messages[0].ID != sent.Message.ID ||
!domain.IsChannelHistoryClearMessage(offsetPage.Messages[0]) {
t.Fatalf("add-offset page after next = %+v, want retained clear anchor %d", offsetPage.Messages, sent.Message.ID)
}
exactCount, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
ChannelID: channelID,
CountOnly: true,
NeedTotalCount: true,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("count history after next message: %v", err)
}
if exactCount.Count != 2 {
t.Fatalf("exact history count after next = %d, want shared message plus clear anchor", exactCount.Count)
}
olderPage, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
ChannelID: channelID,
OffsetID: next.Message.ID,
Limit: 10,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("list older page after next message: %v", err)
}
if len(olderPage.Messages) != 1 ||
olderPage.Messages[0].ID != sent.Message.ID ||
!domain.IsChannelHistoryClearMessage(olderPage.Messages[0]) {
t.Fatalf("older page after next = %+v, want retained clear anchor %d", olderPage.Messages, sent.Message.ID)
}
}
func TestChannelStoreStoryMessageForwardsPublicOnlyAndDeleteRollbackPostgres(t *testing.T) {

View file

@ -12,6 +12,18 @@ import (
)
func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) {
return s.sendChannelMessageWithHooks(ctx, req, channelSendTxHooks{})
}
type channelSendTxHooks struct {
before func(context.Context, pgx.Tx, *domain.SendChannelMessageRequest) error
after func(context.Context, pgx.Tx, domain.SendChannelMessageResult) error
}
// sendChannelMessageWithHooks lets a tightly coupled domain command join the
// channel message/event/PTS transaction. It is deliberately package-private:
// ordinary callers must use SendChannelMessage and may not inject SQL work.
func (s *ChannelStore) sendChannelMessageWithHooks(ctx context.Context, req domain.SendChannelMessageRequest, hooks channelSendTxHooks) (domain.SendChannelMessageResult, error) {
if req.UserID == 0 || req.ChannelID == 0 || (strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() && req.RichMessage.IsZero()) {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
}
@ -27,7 +39,7 @@ func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendCh
}
var lastErr error
for attempt := 0; attempt < retryableChannelTxAttempts; attempt++ {
res, err := s.sendChannelMessageOnce(ctx, req, requestFingerprint)
res, err := s.sendChannelMessageOnce(ctx, req, requestFingerprint, hooks)
if err == nil || !isRetryablePostgresTxError(err) || ctx.Err() != nil {
return res, err
}
@ -36,7 +48,7 @@ func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendCh
return domain.SendChannelMessageResult{}, lastErr
}
func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.SendChannelMessageRequest, requestFingerprint []byte) (domain.SendChannelMessageResult, error) {
func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.SendChannelMessageRequest, requestFingerprint []byte, hooks channelSendTxHooks) (domain.SendChannelMessageResult, error) {
if req.RandomID != 0 && !req.IdempotencyPreflighted {
if dup, found, err := s.LookupChannelSendReplay(ctx, domain.ChannelSendReplayRequest{
ChannelID: req.ChannelID,
@ -125,6 +137,11 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se
p := *req.SendAs
sendAs = &p
}
if hooks.before != nil {
if err := hooks.before(ctx, tx, &req); err != nil {
return domain.SendChannelMessageResult{}, err
}
}
msgID, err := s.msgIDs.NextChannelMessageID(ctx, req.ChannelID)
if err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("allocate channel message id: %w", err)
@ -135,7 +152,7 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se
}
var discussion *domain.SendChannelDiscussionResult
var discussionRef *domain.ChannelDiscussionRef
if channel.Broadcast && channel.LinkedChatID != 0 {
if channel.Broadcast && channel.LinkedChatID != 0 && req.Action == nil {
linked, err := getChannelByID(ctx, tx, channel.LinkedChatID)
if err == nil && !linked.Deleted && linked.Megagroup {
discussionMsgID, err := s.msgIDs.NextChannelMessageID(ctx, linked.ID)
@ -330,6 +347,16 @@ WHERE channel_id = $1 AND user_id = $2 AND unread_mark`, req.ChannelID, req.User
return domain.SendChannelMessageResult{}, err
}
}
txResult := domain.SendChannelMessageResult{
Channel: channel, Message: msg, Event: event, Discussion: discussion,
MentionUserIDs: append([]int64(nil), req.MentionUserIDs...),
SkipDeliveryUserIDs: append([]int64(nil), req.SkipDeliveryUserIDs...),
}
if hooks.after != nil {
if err := hooks.after(ctx, tx, txResult); err != nil {
return domain.SendChannelMessageResult{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("commit send channel: %w", err)
}
@ -342,7 +369,8 @@ WHERE channel_id = $1 AND user_id = $2 AND unread_mark`, req.ChannelID, req.User
discussion.Recipients, _ = s.ListActiveChannelMemberIDs(ctx, req.UserID, discussion.Channel.ID, 0)
}
}
return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: recipients, Discussion: discussion, MentionUserIDs: append([]int64(nil), req.MentionUserIDs...), SkipDeliveryUserIDs: append([]int64(nil), req.SkipDeliveryUserIDs...)}, nil
txResult.Recipients = recipients
return txResult, nil
}
func channelDeliverySkipSet(ids []int64) map[int64]struct{} {

View file

@ -24,7 +24,7 @@ func (s *ChannelStore) GetChannelMessageViews(ctx context.Context, req domain.Ch
if req.UserID == 0 || req.ChannelID == 0 {
return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid
}
channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID)
channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageViewsResult{}, err
}
@ -43,6 +43,16 @@ func (s *ChannelStore) GetChannelMessageViews(ctx context.Context, req domain.Ch
if date <= 0 {
date = nowUnix()
}
args := []any{req.ChannelID, id32, req.UserID, date, member.AvailableMinID}
visibility := ""
if channel.Monoforum && !member.CanManageDirectMessages() {
args = append(args, string(domain.PeerTypeUser), req.UserID)
visibility = fmt.Sprintf(
" AND m.saved_peer_type = $%d AND m.saved_peer_id = $%d",
len(args)-1,
len(args),
)
}
rows, err := s.db.Query(ctx, `
WITH inserted AS (
INSERT INTO channel_message_viewers (channel_id, message_id, viewer_user_id, viewed_at)
@ -52,6 +62,7 @@ WITH inserted AS (
AND m.id = ANY($2::int[])
AND NOT m.deleted
AND m.id > $5
`+visibility+`
ON CONFLICT DO NOTHING
RETURNING message_id
), updated AS (
@ -65,7 +76,7 @@ WITH inserted AS (
)
SELECT i.message_id
FROM inserted i
LEFT JOIN updated u ON u.id = i.message_id`, req.ChannelID, id32, req.UserID, date, member.AvailableMinID)
LEFT JOIN updated u ON u.id = i.message_id`, args...)
if err != nil {
return domain.ChannelMessageViewsResult{}, fmt.Errorf("increment channel message views: %w", err)
}
@ -82,7 +93,7 @@ LEFT JOIN updated u ON u.id = i.message_id`, req.ChannelID, id32, req.UserID, da
}
rows.Close()
}
summaries, err := s.listChannelMessageViewSummaries(ctx, req.ChannelID, id32, member.AvailableMinID)
summaries, err := s.listChannelMessageViewSummaries(ctx, req.UserID, channel, member, id32)
if err != nil {
return domain.ChannelMessageViewsResult{}, err
}
@ -110,13 +121,17 @@ LEFT JOIN updated u ON u.id = i.message_id`, req.ChannelID, id32, req.UserID, da
}, nil
}
func (s *ChannelStore) listChannelMessageViewSummaries(ctx context.Context, channelID int64, ids []int32, availableMinID int) ([]channelMessageViewSummary, error) {
args := []any{channelID, ids}
func (s *ChannelStore) listChannelMessageViewSummaries(ctx context.Context, viewerUserID int64, channel domain.Channel, member domain.ChannelMember, ids []int32) ([]channelMessageViewSummary, error) {
args := []any{channel.ID, ids}
where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted"
if availableMinID > 0 {
args = append(args, availableMinID)
if member.AvailableMinID > 0 {
args = append(args, member.AvailableMinID)
where += fmt.Sprintf(" AND id > $%d", len(args))
}
if channel.Monoforum && !member.CanManageDirectMessages() {
args = append(args, string(domain.PeerTypeUser), viewerUserID)
where += fmt.Sprintf(" AND saved_peer_type = $%d AND saved_peer_id = $%d", len(args)-1, len(args))
}
rows, err := s.db.Query(ctx, `
SELECT id, views_count, post, discussion_channel_id, discussion_message_id, sender_user_id, from_peer_type, from_peer_id
FROM channel_messages

View file

@ -189,6 +189,7 @@ SELECT EXISTS (
Entities: append([]domain.MessageEntity(nil), req.Entities...),
Media: req.Media,
ReplyTo: req.ReplyTo,
Forward: req.Forward,
Pts: pts,
}
event := domain.ChannelUpdateEvent{

View file

@ -71,6 +71,28 @@ func TestChannelStoreEnablingDirectMessagesCreatesMonoforum(t *testing.T) {
if mfTop == 0 || mfPts == 0 {
t.Fatalf("monoforum top/pts = %d/%d, want paid-messages service top", mfTop, mfPts)
}
read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
UserID: owner.ID, ChannelID: monoID, MaxID: mfTop, Date: 1700000901,
})
if err != nil {
t.Fatalf("read synthetic monoforum history: %v", err)
}
if !read.ReadOnly || read.Changed || read.MaxID != mfTop {
t.Fatalf("synthetic monoforum read = %+v, want read-only no-op at %d", read, mfTop)
}
var memberExists, dialogExists bool
if err := pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM channel_members WHERE channel_id=$1 AND user_id=$2
),
EXISTS (
SELECT 1 FROM channel_dialogs WHERE channel_id=$1 AND user_id=$2
)`, monoID, owner.ID).Scan(&memberExists, &dialogExists); err != nil {
t.Fatalf("check synthetic monoforum read footprint: %v", err)
}
if memberExists || dialogExists {
t.Fatalf("synthetic monoforum read persisted member/dialog = %v/%v", memberExists, dialogExists)
}
// 同批下发母广播频道(TDesktop 据此 resolve linked_monoforum_id 并派生 MonoforumAdmin
// 渲染 Direct-Messages 容器):GetChannelDialogs([mono]) 的 chats[] 必须同时带 mono 与母频道。
coDelivery, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{monoID})

View file

@ -79,9 +79,10 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
}
suggestedPost := &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}, ScheduleDate: 1700100000}
forward := &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, Date: 1700000999}
m1, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001001,
SuggestedPost: suggestedPost,
SuggestedPost: suggestedPost, Forward: forward,
})
if err != nil {
t.Fatalf("subscriber send 1: %v", err)
@ -128,7 +129,7 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
}
// 幂等。
dup, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", SuggestedPost: suggestedPost, Date: 1700001004})
dup, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", SuggestedPost: suggestedPost, Forward: forward, Date: 1700001004})
if err != nil {
t.Fatalf("dup send: %v", err)
}
@ -166,6 +167,9 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
if oldest.SuggestedPost == nil || oldest.SuggestedPost.Price == nil || oldest.SuggestedPost.Price.Kind != domain.SuggestedPostPriceStars || oldest.SuggestedPost.Price.Amount != 10 || oldest.SuggestedPost.ScheduleDate != 1700100000 {
t.Fatalf("persisted suggested post = %+v, want 10 Stars + schedule", oldest.SuggestedPost)
}
if oldest.Forward == nil || oldest.Forward.From.ID != owner.ID || oldest.Forward.Date != 1700000999 {
t.Fatalf("persisted monoforum forward = %+v, want source user %d/date 1700000999", oldest.Forward, owner.ID)
}
if newest := hist.Messages[0]; newest.ReplyTo == nil || newest.ReplyTo.MessageID != m1.Message.ID {
t.Fatalf("persisted admin reply = %+v, want message %d", newest.ReplyTo, m1.Message.ID)
}
@ -175,7 +179,8 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
// 另一个订阅者不串会话。
otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID}
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: other.ID, SavedPeer: otherPeer, RandomID: 201, Message: "other", Date: 1700001005}); err != nil {
otherMessage, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: other.ID, SavedPeer: otherPeer, RandomID: 201, Message: "other", Date: 1700001005})
if err != nil {
t.Fatalf("other subscriber send: %v", err)
}
subHist, _ := channels.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: subPeer, Limit: 10})
@ -191,6 +196,126 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
t.Fatalf("subscriber channel history leaked message %+v", message)
}
}
exactMessages, err := channels.GetChannelMessages(ctx, sub.ID, monoID, []int{m1.Message.ID, otherMessage.Message.ID})
if err != nil {
t.Fatalf("subscriber exact monoforum messages: %v", err)
}
if len(exactMessages.Messages) != 1 || exactMessages.Messages[0].ID != m1.Message.ID {
t.Fatalf("subscriber exact monoforum messages = %+v, want only own message %d", exactMessages.Messages, m1.Message.ID)
}
monoBeforeViews, err := channels.GetChannelByID(ctx, monoID)
if err != nil {
t.Fatalf("get monoforum before views: %v", err)
}
subViews, err := channels.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{
UserID: sub.ID, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID},
Increment: true, Date: 1700001006,
})
if err != nil {
t.Fatalf("subscriber get monoforum message views: %v", err)
}
if len(subViews.Views) != 1 || subViews.Views[m1.Message.ID] != 1 {
t.Fatalf("subscriber monoforum views = %+v, want own message %d at 1", subViews.Views, m1.Message.ID)
}
if _, ok := subViews.Views[otherMessage.Message.ID]; ok {
t.Fatalf("subscriber monoforum views leaked other saved_peer message %d", otherMessage.Message.ID)
}
var hiddenViews int
var hiddenViewer bool
if err := pool.QueryRow(ctx, `
SELECT m.views_count,
EXISTS (
SELECT 1
FROM channel_message_viewers v
WHERE v.channel_id = m.channel_id
AND v.message_id = m.id
AND v.viewer_user_id = $3
)
FROM channel_messages m
WHERE m.channel_id = $1 AND m.id = $2`, monoID, otherMessage.Message.ID, sub.ID).Scan(&hiddenViews, &hiddenViewer); err != nil {
t.Fatalf("load hidden monoforum view state: %v", err)
}
if hiddenViews != 0 || hiddenViewer {
t.Fatalf("hidden monoforum view state = count %d viewer %v, want 0/false", hiddenViews, hiddenViewer)
}
repeatedViews, err := channels.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{
UserID: sub.ID, ChannelID: monoID, IDs: []int{m1.Message.ID},
Increment: true, Date: 1700001007,
})
if err != nil || repeatedViews.Views[m1.Message.ID] != 1 {
t.Fatalf("repeated subscriber monoforum views = %+v, %v; want idempotent 1", repeatedViews.Views, err)
}
adminViews, err := channels.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{
UserID: owner.ID, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID},
Increment: true, Date: 1700001008,
})
if err != nil {
t.Fatalf("admin get monoforum message views: %v", err)
}
if len(adminViews.Views) != 2 || adminViews.Views[m1.Message.ID] != 2 || adminViews.Views[otherMessage.Message.ID] != 1 {
t.Fatalf("admin monoforum views = %+v, want both saved peers at 2/1", adminViews.Views)
}
monoAfterViews, err := channels.GetChannelByID(ctx, monoID)
if err != nil {
t.Fatalf("get monoforum after views: %v", err)
}
if monoAfterViews.Pts != monoBeforeViews.Pts {
t.Fatalf("message views advanced monoforum pts = %d, want unchanged %d", monoAfterViews.Pts, monoBeforeViews.Pts)
}
if _, err := channels.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{
UserID: sub.ID, ChannelID: monoID, MessageID: m1.Message.ID,
Reactions: []domain.MessageReaction{{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"}},
Date: 1700001006,
}); err != nil {
t.Fatalf("subscriber react to own monoforum message: %v", err)
}
if _, err := channels.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{
UserID: sub.ID, ChannelID: monoID, MessageID: otherMessage.Message.ID,
Reactions: []domain.MessageReaction{{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f525"}},
Date: 1700001006,
}); !errors.Is(err, domain.ErrMessageIDInvalid) {
t.Fatalf("subscriber react to another saved_peer err = %v, want ErrMessageIDInvalid", err)
}
subReactions, err := channels.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{
UserID: sub.ID, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID},
})
if err != nil {
t.Fatalf("subscriber get monoforum reactions: %v", err)
}
if len(subReactions.Messages) != 1 || subReactions.Messages[0].ID != m1.Message.ID {
t.Fatalf("subscriber monoforum reactions = %+v, want only own message %d", subReactions.Messages, m1.Message.ID)
}
adminReactions, err := channels.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{
UserID: owner.ID, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID},
})
if err != nil {
t.Fatalf("admin get monoforum reactions: %v", err)
}
if len(adminReactions.Messages) != 2 {
t.Fatalf("admin monoforum reactions = %+v, want both subscriber messages", adminReactions.Messages)
}
reactionList, err := channels.ListChannelMessageReactions(ctx, domain.ChannelMessageReactionsListRequest{
UserID: sub.ID, ChannelID: monoID, MessageID: m1.Message.ID, Limit: 10,
})
if err != nil || reactionList.Count != 1 || len(reactionList.Reactions) != 1 {
t.Fatalf("subscriber monoforum reaction list = %+v, %v; want one", reactionList, err)
}
if _, err := channels.ListChannelMessageReactions(ctx, domain.ChannelMessageReactionsListRequest{
UserID: sub.ID, ChannelID: monoID, MessageID: otherMessage.Message.ID, Limit: 10,
}); !errors.Is(err, domain.ErrMessageIDInvalid) {
t.Fatalf("subscriber list another saved_peer reactions err = %v, want ErrMessageIDInvalid", err)
}
reactionLookup, found, err := channels.FindChannelMessageReaction(ctx, domain.ChannelMessageReactionLookupRequest{
ViewerUserID: sub.ID, ChannelID: monoID, MessageID: m1.Message.ID, ReactorUserID: sub.ID,
})
if err != nil || !found || len(reactionLookup.Reactions) != 1 {
t.Fatalf("subscriber monoforum reaction lookup = %+v, %v, %v; want one", reactionLookup, found, err)
}
if _, _, err := channels.FindChannelMessageReaction(ctx, domain.ChannelMessageReactionLookupRequest{
ViewerUserID: sub.ID, ChannelID: monoID, MessageID: otherMessage.Message.ID, ReactorUserID: other.ID,
}); !errors.Is(err, domain.ErrMessageIDInvalid) {
t.Fatalf("subscriber lookup another saved_peer reaction err = %v, want ErrMessageIDInvalid", err)
}
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: sub.ID, ChannelID: monoID, Pts: 0, Limit: 100})
if err != nil {
t.Fatalf("subscriber channel difference: %v", err)

View file

@ -3,6 +3,7 @@ package postgres
import (
"context"
"errors"
"strings"
"testing"
"telesrv/internal/domain"
@ -98,6 +99,70 @@ func TestChannelMultiPin(t *testing.T) {
t.Fatalf("filterPinned message %d lacks pinned flag", msg.ID)
}
}
// 普通历史热路径保留 bounded has-more hint,不为每页额外 COUNT。
hint, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{
ChannelID: channelID, PinnedOnly: true, Limit: 1,
})
if err != nil {
t.Fatalf("filterPinned hint page: %v", err)
}
if len(hint.Messages) != 1 || hint.Count != 2 {
t.Fatalf("filterPinned hint messages/count = %d/%d, want 1/2", len(hint.Messages), hint.Count)
}
// messages.search 首屏显式请求精确总数;页大小不能污染 Count。
exact, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{
ChannelID: channelID, PinnedOnly: true, Limit: 1, NeedTotalCount: true,
})
if err != nil {
t.Fatalf("filterPinned exact page: %v", err)
}
if len(exact.Messages) != 1 || exact.Count != 3 {
t.Fatalf("filterPinned exact messages/count = %d/%d, want 1/3", len(exact.Messages), exact.Count)
}
// messages.getSearchCounters/limit=0 只计数,不加载消息及 reply/reaction companion。
countOnly, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{
ChannelID: channelID, PinnedOnly: true, NeedTotalCount: true, CountOnly: true,
})
if err != nil {
t.Fatalf("filterPinned count-only: %v", err)
}
if len(countOnly.Messages) != 0 || countOnly.Count != 3 {
t.Fatalf("filterPinned count-only messages/count = %d/%d, want 0/3", len(countOnly.Messages), countOnly.Count)
}
// 精确计数只扫描该频道的 pinned 部分索引,不能退化为全频道消息扫描。
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin pinned count explain: %v", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, "SET LOCAL enable_seqscan = off"); err != nil {
t.Fatalf("disable seqscan for pinned count explain: %v", err)
}
rows, err := tx.Query(ctx, `EXPLAIN (COSTS OFF)
SELECT count(*)::int
FROM channel_messages
WHERE channel_id = $1 AND NOT deleted AND pinned`, channelID)
if err != nil {
t.Fatalf("explain pinned count: %v", err)
}
var plan strings.Builder
for rows.Next() {
var line string
if err := rows.Scan(&line); err != nil {
rows.Close()
t.Fatalf("scan pinned count plan: %v", err)
}
plan.WriteString(line)
plan.WriteByte('\n')
}
if err := rows.Err(); err != nil {
rows.Close()
t.Fatalf("read pinned count plan: %v", err)
}
rows.Close()
if !strings.Contains(plan.String(), "channel_messages_live_pinned_idx") {
t.Fatalf("pinned count plan misses partial index:\n%s", plan.String())
}
// 普通历史页的消息行直接携带 pinned 标志(多置顶都标,不只最新)。
page, err := channels.ListChannelHistory(ctx, member.ID, domain.ChannelHistoryFilter{ChannelID: channelID, Limit: 50})
if err != nil {

View file

@ -75,20 +75,61 @@ func TestPublicChannelAndMegagroupPreviewPostgres(t *testing.T) {
if !found || history.Self.Status != domain.ChannelMemberLeft {
t.Fatalf("preview history = %+v self=%+v", history.Messages, history.Self)
}
read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
UserID: viewer.ID, ChannelID: public.ID, MaxID: sent.Message.ID, Date: 1700009411 + i,
})
if err != nil {
t.Fatalf("read public preview history: %v", err)
}
if !read.ReadOnly || read.Changed || read.MaxID != sent.Message.ID {
t.Fatalf("public preview read = %+v, want read-only no-op at %d", read, sent.Message.ID)
}
audience, err := channels.FilterChannelMessageAudienceIDs(ctx, public.ID, []int64{viewer.ID, owner.ID, viewer.ID})
if err != nil {
t.Fatalf("filter public message audience: %v", err)
}
if len(audience) != 2 || audience[0] != owner.ID || audience[1] != viewer.ID {
t.Fatalf("public message audience = %v, want owner/member and viewer/subscriber", audience)
}
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: viewer.ID, ChannelID: public.ID, Pts: created.Event.Pts, Limit: 20,
})
if err != nil {
t.Fatalf("public preview difference: %v", err)
}
if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.NewMessages) != 1 || diff.NewMessages[0].ID != sent.Message.ID {
t.Fatalf("public preview difference = %+v, want sent message through pts %d", diff, sent.Event.Pts)
}
if _, err := channels.GetParticipants(ctx, viewer.ID, public.ID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsRecent}, 0, 20); err != nil {
t.Fatalf("public preview participants: %v", err)
}
if _, err := channels.GetParticipant(ctx, viewer.ID, public.ID, viewer.ID); !errors.Is(err, domain.ErrUserNotParticipant) {
t.Fatalf("public preview self participant err = %v, want ErrUserNotParticipant", err)
}
var memberExists bool
dialogs := appdialogs.NewService(nil, channels)
peerDialogs, err := dialogs.GetPeerDialogs(ctx, viewer.ID, []domain.Peer{{Type: domain.PeerTypeChannel, ID: public.ID}})
if err != nil {
t.Fatalf("public preview peer dialogs: %v", err)
}
if len(peerDialogs.Dialogs) != 1 || len(peerDialogs.ChannelMessages) != 0 || len(peerDialogs.Channels) != 1 {
t.Fatalf("public preview peer dialogs = %+v, want one zero-top bootstrap", peerDialogs)
}
previewDialog := peerDialogs.Dialogs[0]
if previewDialog.TopMessage != 0 || !previewDialog.ChannelLeft ||
previewDialog.ReadInboxMaxID != 0 || previewDialog.ReadOutboxMaxID != 0 ||
previewDialog.Pts != sent.Event.Pts {
t.Fatalf("public preview bootstrap dialog = %+v", previewDialog)
}
var memberExists, dialogExists bool
if err := pool.QueryRow(ctx, `SELECT EXISTS (
SELECT 1 FROM channel_members WHERE channel_id = $1 AND user_id = $2
)`, public.ID, viewer.ID).Scan(&memberExists); err != nil {
), EXISTS (
SELECT 1 FROM channel_dialogs WHERE channel_id = $1 AND user_id = $2
)`, public.ID, viewer.ID).Scan(&memberExists, &dialogExists); err != nil {
t.Fatalf("check preview member row: %v", err)
}
if memberExists {
t.Fatal("public preview persisted a channel member row")
if memberExists || dialogExists {
t.Fatalf("public preview persisted member/dialog = %v/%v", memberExists, dialogExists)
}
if _, err := channels.JoinChannel(ctx, public.ID, viewer.ID, 1700009420+i); err != nil {
t.Fatalf("join public peer: %v", err)
@ -96,6 +137,28 @@ SELECT 1 FROM channel_members WHERE channel_id = $1 AND user_id = $2
if _, err := channels.LeaveChannel(ctx, public.ID, viewer.ID, 1700009430+i); err != nil {
t.Fatalf("leave public peer: %v", err)
}
filtered, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: viewer.ID, ChannelID: public.ID, Pts: sent.Event.Pts, Limit: 20,
})
if err != nil {
t.Fatalf("public difference across participant events: %v", err)
}
if tc.broadcast {
if !filtered.Final || filtered.Pts != sent.Event.Pts || len(filtered.Events) != 0 ||
len(filtered.NewMessages) != 0 || len(filtered.OtherUpdates) != 0 {
t.Fatalf("broadcast difference after transient participant changes = %+v, want unchanged PTS", filtered)
}
} else {
if !filtered.Final || filtered.Pts <= sent.Event.Pts || len(filtered.NewMessages) != 2 ||
len(filtered.OtherUpdates) != 0 {
t.Fatalf("megagroup join/leave difference = %+v, want two real service messages", filtered)
}
for _, message := range filtered.NewMessages {
if message.Action == nil {
t.Fatalf("megagroup join/leave difference message = %+v, want service action", message)
}
}
}
if _, err := channels.GetParticipant(ctx, viewer.ID, public.ID, viewer.ID); !errors.Is(err, domain.ErrUserNotParticipant) {
t.Fatalf("left self participant err = %v, want ErrUserNotParticipant", err)
}
@ -112,6 +175,9 @@ SELECT 1 FROM channel_members WHERE channel_id = $1 AND user_id = $2
t.Fatalf("create private group: %v", err)
}
channelIDs = append(channelIDs, private.Channel.ID)
if audience, err := channels.FilterChannelMessageAudienceIDs(ctx, private.Channel.ID, []int64{viewer.ID}); err != nil || len(audience) != 0 {
t.Fatalf("private message audience = %v err %v, want empty", audience, err)
}
if _, err := channels.ListChannelHistory(ctx, viewer.ID, domain.ChannelHistoryFilter{ChannelID: private.Channel.ID, Limit: 20}); !errors.Is(err, domain.ErrChannelPrivate) {
t.Fatalf("private preview history err = %v, want ErrChannelPrivate", err)
}

View file

@ -121,70 +121,3 @@ func (s *ChannelStore) ClearRecentMessageReactions(ctx context.Context, userID i
}
return nil
}
func (s *ChannelStore) ListSavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) {
if userID == 0 {
return nil, domain.ErrChannelInvalid
}
if limit <= 0 {
return []domain.SavedReactionTag{}, nil
}
if limit > domain.MaxSavedReactionTags {
limit = domain.MaxSavedReactionTags
}
rows, err := s.db.Query(ctx, `
SELECT reaction_type, reaction_value, title, reaction_count
FROM user_saved_reaction_tags
WHERE user_id = $1
ORDER BY reaction_count DESC, updated_at DESC, reaction_type ASC, reaction_value ASC
LIMIT $2`, userID, limit)
if err != nil {
return nil, fmt.Errorf("list saved reaction tags: %w", err)
}
defer rows.Close()
out := make([]domain.SavedReactionTag, 0, limit)
for rows.Next() {
var reactionType, reactionValue, title string
var count int
if err := rows.Scan(&reactionType, &reactionValue, &title, &count); err != nil {
return nil, err
}
reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), reactionValue)
if !ok {
continue
}
out = append(out, domain.SavedReactionTag{
UserID: userID,
Reaction: reaction,
Title: title,
Count: count,
})
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (s *ChannelStore) UpsertSavedReactionTag(ctx context.Context, tag domain.SavedReactionTag) error {
if tag.UserID == 0 || tag.Reaction.Type != domain.MessageReactionEmoji {
return domain.ErrChannelInvalid
}
reactionValue := strings.TrimSpace(tag.Reaction.Emoticon)
if reactionValue == "" {
return domain.ErrChannelInvalid
}
if tag.Count < 0 {
tag.Count = 0
}
if _, err := s.db.Exec(ctx, `
INSERT INTO user_saved_reaction_tags (user_id, reaction_type, reaction_value, title, reaction_count)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, reaction_type, reaction_value) DO UPDATE SET
title = EXCLUDED.title,
reaction_count = GREATEST(user_saved_reaction_tags.reaction_count, EXCLUDED.reaction_count),
updated_at = now()`, tag.UserID, string(tag.Reaction.Type), reactionValue, tag.Title, tag.Count); err != nil {
return fmt.Errorf("upsert saved reaction tag: %w", err)
}
return nil
}

View file

@ -37,7 +37,7 @@ func (s *ChannelStore) SetChannelMessageReactions(ctx context.Context, req domai
_ = tx.Rollback(ctx)
}
}()
channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID)
channel, member, _, err := s.getChannelForViewer(ctx, tx, req.UserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionsResult{}, err
}
@ -57,7 +57,8 @@ func (s *ChannelStore) SetChannelMessageReactions(ctx context.Context, req domai
if err != nil {
return domain.ChannelMessageReactionsResult{}, err
}
if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID {
if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID ||
!channelMessageVisibleToViewer(channel, member, req.UserID, msg) {
return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid
}
// 仅新增/替换受策略约束;空向量是撤销,策略收紧后也必须允许撤销存量 reaction。
@ -474,7 +475,7 @@ func (s *ChannelStore) GetChannelMessageReactions(ctx context.Context, req domai
if len(req.IDs) > domain.MaxGetMessageIDs {
return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid
}
channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID)
channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionsResult{}, err
}
@ -491,6 +492,10 @@ func (s *ChannelStore) GetChannelMessageReactions(ctx context.Context, req domai
args = append(args, member.AvailableMinID)
where += fmt.Sprintf(" AND id > $%d", len(args))
}
if channel.Monoforum && !member.CanManageDirectMessages() {
args = append(args, string(domain.PeerTypeUser), req.UserID)
where += fmt.Sprintf(" AND saved_peer_type = $%d AND saved_peer_id = $%d", len(args)-1, len(args))
}
rows, err := s.db.Query(ctx, `
SELECT `+channelMessageColumns+`
FROM channel_messages
@ -532,7 +537,7 @@ func (s *ChannelStore) ListChannelMessageReactions(ctx context.Context, req doma
if req.Limit <= 0 || req.Limit > domain.MaxChannelMessageReactionListLimit {
req.Limit = domain.MaxChannelMessageReactionListLimit
}
channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID)
channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionsList{}, err
}
@ -543,7 +548,8 @@ func (s *ChannelStore) ListChannelMessageReactions(ctx context.Context, req doma
if err != nil {
return domain.ChannelMessageReactionsList{}, err
}
if msg.Deleted || msg.ID <= member.AvailableMinID {
if msg.Deleted || msg.ID <= member.AvailableMinID ||
!channelMessageVisibleToViewer(channel, member, req.UserID, msg) {
return domain.ChannelMessageReactionsList{}, domain.ErrMessageIDInvalid
}
baseWhere := []string{"channel_id = $1", "message_id = $2"}
@ -612,3 +618,49 @@ LIMIT $`+fmt.Sprint(len(args)), args...)
NextOffset: next,
}, nil
}
func (s *ChannelStore) FindChannelMessageReaction(ctx context.Context, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error) {
if req.ViewerUserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
req.MessageID > domain.MaxMessageBoxID || req.ReactorUserID == 0 {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
}
channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.ViewerUserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
message, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
if message.Deleted || message.ID <= member.AvailableMinID ||
!channelMessageVisibleToViewer(channel, member, req.ViewerUserID, message) {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrMessageIDInvalid
}
rows, err := s.db.Query(ctx, `
SELECT channel_id, message_id, reacted_user_id, sender_user_id,
reaction_type, reaction_value, big, unread, chosen_order, reaction_date
FROM channel_message_reactions
WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3
ORDER BY chosen_order, reaction_type, reaction_value
LIMIT $4`,
req.ChannelID, req.MessageID, req.ReactorUserID,
domain.MaxChannelMessageReactionsPerUser)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, fmt.Errorf("find channel message reaction: %w", err)
}
defer rows.Close()
reactions := make([]domain.ChannelMessagePeerReaction, 0, domain.MaxChannelMessageReactionsPerUser)
for rows.Next() {
reaction, err := scanChannelMessagePeerReaction(rows, req.ViewerUserID)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
reactions = append(reactions, reaction)
}
if err := rows.Err(); err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
return domain.ChannelMessageReactionLookup{
Channel: channel, Message: message, Reactions: reactions,
}, len(reactions) > 0, nil
}

View file

@ -574,8 +574,10 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedTop,
if err != nil {
t.Fatalf("get large channel after local clear: %v", err)
}
if afterClear.Dialog.TopMessageID != 0 || afterClear.Dialog.UnreadCount != 0 {
t.Fatalf("large dialog after local clear = %+v, want no visible unread top", afterClear.Dialog)
if afterClear.Dialog.TopMessageID != sent.Message.ID ||
afterClear.Dialog.HistoryClearAnchorID != sent.Message.ID ||
afterClear.Dialog.UnreadCount != 0 {
t.Fatalf("large dialog after local clear = %+v, want anchored top %d with no unread", afterClear.Dialog, sent.Message.ID)
}
}

View file

@ -6,6 +6,9 @@ import (
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
@ -235,7 +238,7 @@ func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChan
if strings.EqualFold(channel.Username, username) {
return domain.Channel{}, domain.ErrChannelNotModified
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, req.ChannelID, usernameLower); err != nil {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, req.ChannelID, username, usernameLower); err != nil {
return domain.Channel{}, err
}
if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), updated_at = now() WHERE id = $1`, req.ChannelID, username); err != nil {
@ -292,16 +295,52 @@ func (s *ChannelStore) SetChannelScamFake(ctx context.Context, channelID int64,
if scam && fake {
return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
}
channel, err := s.channelByID(ctx, s.db, channelID)
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.Channel{}, fmt.Errorf("set channel scam/fake: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.Channel{}, fmt.Errorf("begin set channel scam/fake: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
var currentScam, currentFake bool
if err := tx.QueryRow(ctx, `
SELECT scam, fake
FROM channels
WHERE id = $1 AND NOT deleted
FOR UPDATE`, channelID).Scan(&currentScam, &currentFake); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Channel{}, domain.ErrChannelInvalid
}
return domain.Channel{}, fmt.Errorf("lock channel scam/fake: %w", err)
}
channel, err := s.channelByID(ctx, tx, channelID)
if err != nil {
return domain.Channel{}, err
}
if channel.Scam == scam && channel.Fake == fake {
if err := tx.Commit(ctx); err != nil {
return domain.Channel{}, fmt.Errorf("commit unchanged channel scam/fake: %w", err)
}
committed = true
return channel, nil
}
if _, err := s.db.Exec(ctx, `UPDATE channels SET scam = $2, fake = $3, updated_at = now() WHERE id = $1 AND NOT deleted`, channelID, scam, fake); err != nil {
if currentScam != channel.Scam || currentFake != channel.Fake {
return domain.Channel{}, fmt.Errorf("channel scam/fake snapshot changed while locked")
}
if _, err := tx.Exec(ctx, `UPDATE channels SET scam = $2, fake = $3, updated_at = now() WHERE id = $1 AND NOT deleted`, channelID, scam, fake); err != nil {
return domain.Channel{}, fmt.Errorf("set channel scam/fake: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.Channel{}, fmt.Errorf("commit channel scam/fake: %w", err)
}
committed = true
if s.rowCache != nil {
s.rowCache.delete(channelID)
}
@ -387,7 +426,7 @@ func (s *ChannelStore) SetChannelUsernameAdmin(ctx context.Context, channelID in
if strings.EqualFold(channel.Username, username) {
return channel, nil
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, channelID, usernameLower); err != nil {
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, channelID, username, usernameLower); err != nil {
return domain.Channel{}, err
}
if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), updated_at = now() WHERE id = $1`, channelID, username); err != nil {
@ -471,6 +510,9 @@ func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerU
if !found || owner.peerType != peerUsernameTypeChannel {
return domain.Channel{}, false, nil
}
if !owner.active {
return domain.Channel{}, false, nil
}
ch, err := getChannelByID(ctx, s.db, owner.peerID)
if err != nil {
if errors.Is(err, domain.ErrChannelInvalid) {
@ -478,7 +520,14 @@ func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerU
}
return domain.Channel{}, false, fmt.Errorf("resolve public channel username channel: %w", err)
}
if !publicPreviewableChannel(ch) || !strings.EqualFold(ch.Username, usernameLower) {
if !publicPreviewableChannel(ch, true) {
return domain.Channel{}, false, nil
}
// A collectible row is authoritative for its own name: the channel's scalar
// username column only ever mirrors the editable slot, so re-checking it here
// would make collectible names unresolvable. The scalar comparison stays for
// the editable slot, where it guards against a stale registry row.
if !owner.collectible && !strings.EqualFold(ch.Username, usernameLower) {
return domain.Channel{}, false, nil
}
return ch, true, nil

View file

@ -118,8 +118,12 @@ func (s *ChannelStore) ToggleSuggestedPostApproval(ctx context.Context, req doma
if req.ScheduleDate > 0 {
scheduleDate = req.ScheduleDate
}
if !req.Reject && scheduleDate > 0 && (scheduleDate < req.Date+5*60 || scheduleDate > req.Date+31*24*60*60) {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
if !req.Reject {
effectiveDate, scheduleErr := domain.EffectiveSuggestedPostPublishDate(scheduleDate, req.Date)
if scheduleErr != nil {
return domain.ToggleSuggestedPostApprovalResult{}, scheduleErr
}
scheduleDate = effectiveDate
}
recipients, err := monoforumManagerRecipientsTx(ctx, tx, parent.ID, original.SavedPeer.ID)
if err != nil {
@ -173,11 +177,6 @@ func (s *ChannelStore) ToggleSuggestedPostApproval(ctx context.Context, req doma
}
} else {
effectivePublishDate := scheduleDate
if effectivePublishDate == 0 {
// TDesktop's "Publish Now" request has no schedule_date flag, but
// its approval service renderer always expects an absolute date.
effectivePublishDate = req.Date
}
original.SuggestedPost.Accepted, original.SuggestedPost.Rejected, original.SuggestedPost.ScheduleDate = true, false, effectivePublishDate
original, result.OriginalEvent, err = s.persistSuggestedPostEditTx(ctx, tx, original, req.UserID, req.Date)
if err != nil {

View file

@ -133,3 +133,122 @@ func TestSuggestedPostLifecyclePostgres(t *testing.T) {
t.Fatalf("late settlement balance/channel=%d/%d, want 90/8", debit, channelBalance)
}
}
func TestSuggestedPostApprovalAcceptsDelayedSchedulePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 211, Phone: "+1889" + suffix + "01", FirstName: "DelayedOwner"})
if err != nil {
t.Fatal(err)
}
subscriber, err := users.Create(ctx, domain.User{AccessHash: 212, Phone: "+1889" + suffix + "02", FirstName: "DelayedSubscriber"})
if err != nil {
t.Fatal(err)
}
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "Delayed Suggested " + suffix, Broadcast: true, Date: 1_700_020_000,
})
if err != nil {
t.Fatal(err)
}
enabled, err := channels.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true)
if err != nil {
t.Fatal(err)
}
monoID := enabled.Channel.LinkedMonoforumID
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM suggested_post_approvals WHERE monoforum_id=$1`, monoID)
_, _ = pool.Exec(ctx, `DELETE FROM channels WHERE id=ANY($1::bigint[])`, []int64{monoID, created.Channel.ID})
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=ANY($1::bigint[])`, []int64{owner.ID, subscriber.ID})
})
saved := domain.Peer{Type: domain.PeerTypeUser, ID: subscriber.ID}
const now = 1_700_020_100
near, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: monoID, SenderUserID: subscriber.ID, SavedPeer: saved,
RandomID: 81, Message: "postgres near schedule", SuggestedPost: &domain.SuggestedPost{}, Date: now - 10,
})
if err != nil {
t.Fatal(err)
}
nearDate := now + 2*60
accepted, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
UserID: owner.ID, MonoforumID: monoID, MessageID: near.Message.ID,
ScheduleDate: nearDate, Date: now,
})
if err != nil || accepted.State != domain.SuggestedPostStateScheduled || accepted.Published != nil {
t.Fatalf("near schedule approval=%+v err=%v", accepted, err)
}
var monoPts, parentPts, monoEvents, parentEvents int
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, monoID).Scan(&monoPts); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&parentPts); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM channel_update_events WHERE channel_id=$1`, monoID).Scan(&monoEvents); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM channel_update_events WHERE channel_id=$1`, created.Channel.ID).Scan(&parentEvents); err != nil {
t.Fatal(err)
}
replay, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
UserID: owner.ID, MonoforumID: monoID, MessageID: near.Message.ID,
ScheduleDate: nearDate, Date: now + 10*60,
})
if err != nil || !replay.Duplicate {
t.Fatalf("late replay=%+v err=%v", replay, err)
}
var gotMonoPts, gotParentPts, gotMonoEvents, gotParentEvents int
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, monoID).Scan(&gotMonoPts); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&gotParentPts); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM channel_update_events WHERE channel_id=$1`, monoID).Scan(&gotMonoEvents); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM channel_update_events WHERE channel_id=$1`, created.Channel.ID).Scan(&gotParentEvents); err != nil {
t.Fatal(err)
}
if gotMonoPts != monoPts || gotParentPts != parentPts || gotMonoEvents != monoEvents || gotParentEvents != parentEvents {
t.Fatalf("late replay changed pts/events mono=%d/%d→%d/%d parent=%d/%d→%d/%d",
monoPts, monoEvents, gotMonoPts, gotMonoEvents, parentPts, parentEvents, gotParentPts, gotParentEvents)
}
due, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: monoID, SenderUserID: subscriber.ID, SavedPeer: saved,
RandomID: 82, Message: "postgres due schedule", SuggestedPost: &domain.SuggestedPost{}, Date: now + 20,
})
if err != nil {
t.Fatal(err)
}
approvedAt := now + 30
dueResult, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
UserID: owner.ID, MonoforumID: monoID, MessageID: due.Message.ID,
ScheduleDate: now - 1, Date: approvedAt,
})
if err != nil || dueResult.State != domain.SuggestedPostStateCompleted || dueResult.Published == nil {
t.Fatalf("due approval=%+v err=%v", dueResult, err)
}
if dueResult.OriginalMessage.SuggestedPost.ScheduleDate != approvedAt ||
dueResult.ServiceMessage.Action == nil ||
dueResult.ServiceMessage.Action.SuggestedPostScheduleDate != approvedAt {
t.Fatalf("due effective dates original/action=%d/%+v, want %d",
dueResult.OriginalMessage.SuggestedPost.ScheduleDate, dueResult.ServiceMessage.Action, approvedAt)
}
var persistedDate int
if err := pool.QueryRow(ctx, `
SELECT schedule_date
FROM suggested_post_approvals
WHERE monoforum_id=$1 AND suggestion_message_id=$2`, monoID, due.Message.ID).Scan(&persistedDate); err != nil {
t.Fatal(err)
}
if persistedDate != approvedAt {
t.Fatalf("persisted due schedule=%d, want %d", persistedDate, approvedAt)
}
}

Some files were not shown because too many files have changed in this diff Show more