feat: sync public links and phone change updates

This commit is contained in:
A 2026-07-10 22:01:44 +08:00
parent 41c7f1d018
commit da04c0fa6a
53 changed files with 3029 additions and 111 deletions

View file

@ -285,9 +285,7 @@ func (s *ChannelStore) SetChannelVerified(ctx context.Context, channelID int64,
}
func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) {
if viewerUserID == 0 {
return domain.Channel{}, false, domain.ErrChannelInvalid
}
_ = viewerUserID // zero is the anonymous public-web view; this query is viewer-independent.
usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
if usernameLower == "" {
return domain.Channel{}, false, nil

View file

@ -59,6 +59,10 @@ func TestChannelStoreResolvePublicUsernameRejectsStaleIndex(t *testing.T) {
if _, found, err := channels.ResolvePublicChannelUsername(ctx, viewer.ID, usernames[1]); err != nil || found {
t.Fatalf("resolve missing username found %v err %v, want not found", found, err)
}
anonymous, found, err := channels.ResolvePublicChannelUsername(ctx, 0, strings.ToUpper(publicUsername))
if err != nil || !found || anonymous.ID != publicChannel.ID {
t.Fatalf("anonymous resolve public username = %+v found=%v err=%v", anonymous, found, err)
}
if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{
UserID: owner.ID,
ChannelID: publicChannel.ID,

View file

@ -0,0 +1,112 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// PhoneChangeStore 把 users.phone、账号 pts、durable event 与 dispatch outbox
// 作为一个事务提交,避免任何一边单独可见。
type PhoneChangeStore struct {
db sqlcgen.DBTX
q *sqlcgen.Queries
}
func NewPhoneChangeStore(db sqlcgen.DBTX) *PhoneChangeStore {
return &PhoneChangeStore{db: db, q: sqlcgen.New(db)}
}
func (*PhoneChangeStore) UsesReliableDispatch() bool { return true }
func (s *PhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChangeRequest) (domain.PhoneChangeResult, error) {
if s == nil || req.UserID == 0 || !domain.ValidPhone(req.Phone) {
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.PhoneChangeResult{}, fmt.Errorf("change phone: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.PhoneChangeResult{}, fmt.Errorf("begin change phone: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
qtx := s.q.WithTx(tx)
var currentPhone string
if err := tx.QueryRow(ctx, `SELECT phone FROM users WHERE id = $1 FOR UPDATE`, req.UserID).Scan(&currentPhone); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.PhoneChangeResult{}, domain.ErrUserNotFound
}
return domain.PhoneChangeResult{}, fmt.Errorf("lock user for phone change: %w", err)
}
if currentPhone == req.Phone {
row, err := qtx.GetUserByID(ctx, req.UserID)
if err != nil {
return domain.PhoneChangeResult{}, fmt.Errorf("reload unchanged phone user: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.PhoneChangeResult{}, fmt.Errorf("commit unchanged phone: %w", err)
}
committed = true
return domain.PhoneChangeResult{User: userFromModel(row)}, nil
}
row, err := qtx.UpdateUserPhone(ctx, sqlcgen.UpdateUserPhoneParams{ID: req.UserID, Phone: req.Phone})
if err != nil {
if isUniqueConstraint(err, "users_phone_unique_idx") {
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied
}
if errors.Is(err, pgx.ErrNoRows) {
return domain.PhoneChangeResult{}, domain.ErrUserNotFound
}
return domain.PhoneChangeResult{}, fmt.Errorf("update user phone: %w", err)
}
date := req.Date
if date == 0 {
date = int(time.Now().Unix())
}
event := domain.UpdateEvent{
UserID: req.UserID,
Type: domain.UpdateEventUserPhone,
Date: date,
Phone: req.Phone,
PtsCount: 1,
}
event.Pts, err = reserveUserPts(ctx, tx, req.UserID, event.PtsCount)
if err != nil {
return domain.PhoneChangeResult{}, fmt.Errorf("reserve phone change pts: %w", err)
}
if err := appendUserUpdateEvent(ctx, tx, qtx, req.UserID, event); err != nil {
return domain.PhoneChangeResult{}, fmt.Errorf("append phone change event: %w", err)
}
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
TargetUserID: req.UserID,
Pts: int32(event.Pts),
EventType: string(event.Type),
ExcludeAuthKeyID: authKeyIDToInt64(req.ExcludeAuthKeyID),
ExcludeSessionID: req.ExcludeSessionID,
}); err != nil {
return domain.PhoneChangeResult{}, fmt.Errorf("enqueue phone change dispatch: %w", err)
}
if err := tx.Commit(ctx); err != nil {
if isUniqueConstraint(err, "users_phone_unique_idx") {
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied
}
return domain.PhoneChangeResult{}, fmt.Errorf("commit phone change: %w", err)
}
committed = true
return domain.PhoneChangeResult{User: userFromModel(row), Event: event, Changed: true}, nil
}

View file

@ -0,0 +1,86 @@
package postgres
import (
"context"
"errors"
"fmt"
"testing"
"time"
"telesrv/internal/domain"
)
func TestPhoneChangeStoreAtomicUserEventOutboxPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
users := NewUserStore(pool)
changes := NewPhoneChangeStore(pool)
events := NewUpdateEventStore(pool)
suffix := time.Now().UnixNano() % 1_000_000_000
oldPhone := fmt.Sprintf("1661%d01", suffix)
occupiedPhone := fmt.Sprintf("1661%d02", suffix)
newPhone := fmt.Sprintf("1661%d03", suffix)
u1, err := users.Create(ctx, domain.User{AccessHash: 301, Phone: oldPhone, FirstName: "PhoneOne"})
if err != nil {
t.Fatalf("create user1: %v", err)
}
u2, err := users.Create(ctx, domain.User{AccessHash: 302, Phone: occupiedPhone, FirstName: "PhoneTwo"})
if err != nil {
t.Fatalf("create user2: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", []int64{u1.ID, u2.ID})
_, _ = pool.Exec(context.Background(), "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", []int64{u1.ID, u2.ID})
_, _ = pool.Exec(context.Background(), "DELETE FROM user_update_watermarks WHERE user_id = ANY($1::bigint[])", []int64{u1.ID, u2.ID})
_, _ = pool.Exec(context.Background(), "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{u1.ID, u2.ID})
})
authKeyID := [8]byte{7, 6, 5, 4}
result, err := changes.ChangePhone(ctx, domain.PhoneChangeRequest{
UserID: u1.ID, Phone: newPhone, Date: 1700000001,
ExcludeAuthKeyID: authKeyID, ExcludeSessionID: 77,
})
if err != nil {
t.Fatalf("change phone: %v", err)
}
if !result.Changed || result.User.Phone != newPhone || result.Event.Pts != 1 || result.Event.Phone != newPhone {
t.Fatalf("result = %+v", result)
}
loaded, found, err := users.ByID(ctx, u1.ID)
if err != nil || !found || loaded.Phone != newPhone {
t.Fatalf("loaded user = %+v found=%v err=%v", loaded, found, err)
}
storedEvents, err := events.ListAfter(ctx, u1.ID, 0, 10)
if err != nil || len(storedEvents) != 1 || storedEvents[0].Type != domain.UpdateEventUserPhone || storedEvents[0].Phone != newPhone {
t.Fatalf("stored events = %+v err=%v", storedEvents, err)
}
var outboxCount int
var excludedAuth, excludedSession int64
if err := pool.QueryRow(ctx, `SELECT count(*), max(exclude_auth_key_id), max(exclude_session_id) FROM dispatch_outbox WHERE target_user_id = $1 AND pts = $2`, u1.ID, result.Event.Pts).Scan(&outboxCount, &excludedAuth, &excludedSession); err != nil {
t.Fatalf("query outbox: %v", err)
}
if outboxCount != 1 || excludedAuth != authKeyIDToInt64(authKeyID) || excludedSession != 77 {
t.Fatalf("outbox count/auth/session = %d/%d/%d", outboxCount, excludedAuth, excludedSession)
}
// 同号重试是幂等读,不得重复推进 pts 或重复入 outbox。
retry, err := changes.ChangePhone(ctx, domain.PhoneChangeRequest{UserID: u1.ID, Phone: newPhone, Date: 1700000002})
if err != nil || retry.Changed || retry.Event.Pts != 0 || retry.User.Phone != newPhone {
t.Fatalf("idempotent retry = %+v err=%v", retry, err)
}
if pts, err := events.MaxContiguousPts(ctx, u1.ID); err != nil || pts != 1 {
t.Fatalf("pts after retry = %d err=%v", pts, err)
}
// 冲突更新整体回滚:号码和 pts/event 都不变。
if _, err := changes.ChangePhone(ctx, domain.PhoneChangeRequest{UserID: u2.ID, Phone: newPhone}); !errors.Is(err, domain.ErrPhoneNumberOccupied) {
t.Fatalf("occupied change err = %v", err)
}
loaded2, found, err := users.ByID(ctx, u2.ID)
if err != nil || !found || loaded2.Phone != occupiedPhone {
t.Fatalf("occupied rollback user = %+v found=%v err=%v", loaded2, found, err)
}
if pts, err := events.MaxContiguousPts(ctx, u2.ID); err != nil || pts != 0 {
t.Fatalf("occupied rollback pts = %d err=%v", pts, err)
}
}

View file

@ -125,6 +125,13 @@ SET first_name = $2,
WHERE id = $1
RETURNING *;
-- name: UpdateUserPhone :one
UPDATE users
SET phone = sqlc.arg(phone)::text,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint
RETURNING *;
-- name: SetUserPremiumUntil :one
UPDATE users
SET premium_expires_at = sqlc.narg(premium_expires_at)::timestamptz,

View file

@ -6,6 +6,7 @@ INSERT INTO user_update_events (
date,
event_type,
event_bool,
event_phone,
event_peers,
peer_settings,
message_ids,
@ -30,6 +31,7 @@ INSERT INTO user_update_events (
$4,
$5,
sqlc.arg(event_bool)::boolean,
sqlc.arg(event_phone)::text,
sqlc.arg(event_peers)::jsonb,
sqlc.arg(peer_settings)::jsonb,
sqlc.arg(message_ids)::jsonb,
@ -57,6 +59,7 @@ SELECT
e.date,
e.event_type,
e.event_bool,
e.event_phone,
COALESCE(e.event_peers::text, '[]')::text AS event_peers_json,
COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json,
COALESCE(e.message_ids::text, '[]')::text AS message_ids_json,
@ -267,6 +270,7 @@ SELECT
e.date,
e.event_type,
e.event_bool,
e.event_phone,
COALESCE(e.event_peers::text, '[]')::text AS event_peers_json,
COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json,
COALESCE(e.message_ids::text, '[]')::text AS message_ids_json,

View file

@ -7,6 +7,8 @@ package sqlcgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const getAuthKey = `-- name: GetAuthKey :one
@ -15,9 +17,16 @@ FROM auth_keys
WHERE auth_key_id = $1
`
func (q *Queries) GetAuthKey(ctx context.Context, authKeyID int64) (AuthKey, error) {
type GetAuthKeyRow struct {
AuthKeyID int64
Body []byte
ServerSalt int64
CreatedAt pgtype.Timestamptz
}
func (q *Queries) GetAuthKey(ctx context.Context, authKeyID int64) (GetAuthKeyRow, error) {
row := q.db.QueryRow(ctx, getAuthKey, authKeyID)
var i AuthKey
var i GetAuthKeyRow
err := row.Scan(
&i.AuthKeyID,
&i.Body,

View file

@ -164,10 +164,16 @@ type AttachMenuUserState struct {
}
type AuthKey struct {
AuthKeyID int64
Body []byte
ServerSalt int64
CreatedAt pgtype.Timestamptz
AuthKeyID int64
Body []byte
ServerSalt int64
CreatedAt pgtype.Timestamptz
Layer int32
DeviceModel string
Platform string
SystemVersion string
ApiID int32
AppVersion string
}
type Authorization struct {
@ -201,6 +207,22 @@ type AvailableReaction struct {
SortOrder int32
}
type BootstrapUpdateJob struct {
ID int64
Kind string
UserID int64
AuthKeyID int64
SessionID int64
MessageBoxID int32
Status string
Attempts int32
LastError string
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
ReadyAt pgtype.Timestamptz
PublishedAt pgtype.Timestamptz
}
type Bot struct {
BotUserID int64
OwnerUserID int64
@ -218,6 +240,24 @@ type Bot struct {
BotInlineGeo bool
}
type BotApiUpdate struct {
ID int64
BotUserID int64
UpdateKind string
PeerType string
PeerID int64
MessageID int32
SourcePts int32
Date int32
CreatedAt pgtype.Timestamptz
}
type BotApiUpdateState struct {
BotUserID int64
ConfirmedUpdateID int64
UpdatedAt pgtype.Timestamptz
}
type BotApp struct {
ID int64
BotUserID int64
@ -663,6 +703,30 @@ type ChannelUpdateEvent struct {
CreatedAt pgtype.Timestamptz
}
type ChatlistInvite struct {
ID int64
OwnerUserID int64
FilterID int32
Slug string
Title string
Peers []byte
Revoked bool
Deleted bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type ChatlistMembership struct {
UserID int64
LocalFilterID int32
OwnerUserID int64
OwnerFilterID int32
Slug string
HiddenUpdates bool
JoinedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type Contact struct {
UserID int64
ContactUserID int64
@ -857,6 +921,8 @@ type GroupCall struct {
InviteLink string
RandomID int64
MigratedFromPhoneCallID int64
RtmpStream bool
ScheduleDate int32
}
type GroupCallChainBlock struct {
@ -895,6 +961,7 @@ type GroupCallParticipant struct {
LastCheckDate int32
PublicKey []byte
JoinBlock []byte
JoinAsChannelID int64
}
type GroupCallParticipantOverride struct {
@ -905,6 +972,17 @@ type GroupCallParticipantOverride struct {
Volume int32
}
type GroupCallRtmpKey struct {
ChannelID int64
StreamKey string
UpdatedAt int32
}
type GroupCallScheduleSubscriber struct {
CallID int64
UserID int64
}
type LangPack struct {
LangPack string
LangCode string
@ -1197,6 +1275,7 @@ type ScheduledMessage struct {
Body string
Entities []byte
Media []byte
RichMessage []byte
Silent bool
Noforwards bool
ReplyToMsgID int32
@ -1555,6 +1634,7 @@ type UserUpdateEvent struct {
QuickReplyMessage []byte
StoryPayload []byte
ReactionPayload []byte
EventPhone string
}
type UserUpdateWatermark struct {

View file

@ -881,6 +881,56 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP
return i, err
}
const updateUserPhone = `-- name: UpdateUserPhone :one
UPDATE users
SET phone = $1::text,
updated_at = now()
WHERE id = $2::bigint
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
`
type UpdateUserPhoneParams struct {
Phone string
ID int64
}
func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams) (User, error) {
row := q.db.QueryRow(ctx, updateUserPhone, arg.Phone, arg.ID)
var i User
err := row.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
&i.DefaultHistoryTtlPeriod,
&i.IsBot,
&i.BotInfoVersion,
&i.PremiumExpiresAt,
&i.EmojiStatusDocumentID,
&i.EmojiStatusUntil,
&i.ColorSet,
&i.Color,
&i.ColorBackgroundEmojiID,
&i.ProfileColorSet,
&i.ProfileColor,
&i.ProfileColorBackgroundEmojiID,
&i.BirthdayDay,
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
)
return i, err
}
const updateUserProfile = `-- name: UpdateUserProfile :one
UPDATE users
SET first_name = $2,

View file

@ -17,6 +17,7 @@ INSERT INTO user_update_events (
date,
event_type,
event_bool,
event_phone,
event_peers,
peer_settings,
message_ids,
@ -41,7 +42,7 @@ INSERT INTO user_update_events (
$4,
$5,
$6::boolean,
$7::jsonb,
$7::text,
$8::jsonb,
$9::jsonb,
$10::jsonb,
@ -49,15 +50,16 @@ INSERT INTO user_update_events (
$12::jsonb,
$13::jsonb,
$14::jsonb,
$15,
$16::text,
$17::bigint,
$18::int,
$15::jsonb,
$16,
$17::text,
$18::bigint,
$19::int,
$20::int,
$21::int,
$22::boolean,
$23::int
$22::int,
$23::boolean,
$24::int
)
`
@ -68,6 +70,7 @@ type AppendUserUpdateEventParams struct {
Date int32
EventType string
EventBool bool
EventPhone string
EventPeers []byte
PeerSettings []byte
MessageIds []byte
@ -95,6 +98,7 @@ func (q *Queries) AppendUserUpdateEvent(ctx context.Context, arg AppendUserUpdat
arg.Date,
arg.EventType,
arg.EventBool,
arg.EventPhone,
arg.EventPeers,
arg.PeerSettings,
arg.MessageIds,
@ -124,6 +128,7 @@ SELECT
e.date,
e.event_type,
e.event_bool,
e.event_phone,
COALESCE(e.event_peers::text, '[]')::text AS event_peers_json,
COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json,
COALESCE(e.message_ids::text, '[]')::text AS message_ids_json,
@ -260,6 +265,7 @@ type BatchListDispatchEventsRow struct {
Date int32
EventType string
EventBool bool
EventPhone string
EventPeersJson string
PeerSettingsJson string
MessageIdsJson string
@ -394,6 +400,7 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp
&i.Date,
&i.EventType,
&i.EventBool,
&i.EventPhone,
&i.EventPeersJson,
&i.PeerSettingsJson,
&i.MessageIdsJson,
@ -681,6 +688,7 @@ SELECT
e.date,
e.event_type,
e.event_bool,
e.event_phone,
COALESCE(e.event_peers::text, '[]')::text AS event_peers_json,
COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json,
COALESCE(e.message_ids::text, '[]')::text AS message_ids_json,
@ -820,6 +828,7 @@ type ListUserUpdateEventsAfterRow struct {
Date int32
EventType string
EventBool bool
EventPhone string
EventPeersJson string
PeerSettingsJson string
MessageIdsJson string
@ -952,6 +961,7 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd
&i.Date,
&i.EventType,
&i.EventBool,
&i.EventPhone,
&i.EventPeersJson,
&i.PeerSettingsJson,
&i.MessageIdsJson,

View file

@ -218,6 +218,7 @@ func appendUserUpdateEvent(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Quer
Date: int32(event.Date),
EventType: string(event.Type),
EventBool: event.Bool,
EventPhone: event.Phone,
EventPeers: peers,
PeerSettings: settings,
MessageIds: messageIDs,
@ -406,6 +407,7 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim
Story: story,
Peers: peers,
Bool: row.EventBool,
Phone: row.EventPhone,
Settings: settings,
MessageIDs: messageIDs,
MaxID: int(row.MaxID),
@ -599,6 +601,7 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev
Story: story,
Peers: peers,
Bool: row.EventBool,
Phone: row.EventPhone,
Settings: settings,
MessageIDs: messageIDs,
MaxID: int(row.MaxID),