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

@ -37,6 +37,9 @@ type ChannelStore interface {
ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
ListSendAsChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
// ResolvePublicChannelUsername resolves an active public channel/supergroup.
// viewerUserID may be zero for anonymous public-link projection; this lookup
// never returns viewer-specific membership or dialog state.
ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error)
SearchPublicChannels(ctx context.Context, viewerUserID int64, query string, limit int) (domain.PublicChannelSearchResult, error)
SetSignatures(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)

View file

@ -5,11 +5,18 @@ import (
"time"
)
// PhoneCode 是一条登录验证码记录(与某次 sendCode 的 phone_code_hash 或邮箱验证键关联)。
const PhoneCodePurposeChangePhone = "change_phone"
// PhoneCode 是一条验证码记录(与某次 sendCode 的 phone_code_hash 或邮箱验证键关联)。
// Purpose/UserID/AuthKeyID/SessionID 为已登录敏感操作提供作用域;登录验证码保持零值。
type PhoneCode struct {
Phone string
Code string
Channel string
Purpose string
UserID int64
AuthKeyID [8]byte
SessionID int64
Email string
PendingEmail string
Attempts int
@ -19,11 +26,39 @@ type PhoneCode struct {
LoginEmailHash string
}
// CodeStore 暂存登录验证码phone_code_hash → 手机号 + 验证码,带 TTL。
// PhoneCodeScope 标识已登录敏感操作的一次性验证码作用域。SessionID 故意不在
// 作用域内:同一 perm auth key 等待验证码期间允许重建 MTProto session。
// 登录/注册验证码没有 Purpose/UserID/AuthKeyID保持非 scoped 行为。
type PhoneCodeScope struct {
Purpose string
UserID int64
AuthKeyID [8]byte
Phone string
}
func (c PhoneCode) Scope() PhoneCodeScope {
return PhoneCodeScope{
Purpose: c.Purpose,
UserID: c.UserID,
AuthKeyID: c.AuthKeyID,
Phone: c.Phone,
}
}
func (s PhoneCodeScope) Valid() bool {
return s.Purpose != "" && s.UserID != 0 && s.AuthKeyID != ([8]byte{}) && s.Phone != ""
}
// CodeStore 暂存验证码phone_code_hash → 作用域 + 手机号 + 验证码,带 TTL。
// 实现见 store/memory测试替身、store/redisstore。
type CodeStore interface {
// Set 对 scoped code 必须原子替换同作用域旧 hash保证单作用域至多一个
// 活跃验证码;普通登录码仍按 hash 独立保存。
Set(ctx context.Context, phoneCodeHash string, code PhoneCode, ttl time.Duration) error
Get(ctx context.Context, phoneCodeHash string) (PhoneCode, bool, error)
Update(ctx context.Context, phoneCodeHash string, code PhoneCode) error
Del(ctx context.Context, phoneCodeHash string) error
// ConsumeScoped 仅当 hash 仍是 scope 的当前活跃 hash 时原子读取并删除;
// 并发调用至多一个返回 found=true。
ConsumeScoped(ctx context.Context, phoneCodeHash string, scope PhoneCodeScope) (PhoneCode, bool, error)
}

View file

@ -262,17 +262,28 @@ func (s *AuthorizationStore) DeleteByUserExcept(_ context.Context, userID int64,
// CodeStore 是 store.CodeStore 的内存实现(带 TTL
type CodeStore struct {
mu sync.Mutex
m map[string]codeEntry
mu sync.Mutex
m map[string]codeEntry
scopes map[store.PhoneCodeScope]string
}
// NewCodeStore 创建内存 CodeStore。
func NewCodeStore() *CodeStore {
return &CodeStore{m: make(map[string]codeEntry)}
return &CodeStore{
m: make(map[string]codeEntry),
scopes: make(map[store.PhoneCodeScope]string),
}
}
func (s *CodeStore) Set(_ context.Context, hash string, code store.PhoneCode, ttl time.Duration) error {
s.mu.Lock()
scope := code.Scope()
if scope.Valid() {
if oldHash, ok := s.scopes[scope]; ok && oldHash != hash {
delete(s.m, oldHash)
}
s.scopes[scope] = hash
}
s.m[hash] = codeEntry{code: code, expires: time.Now().Add(ttl)}
s.mu.Unlock()
return nil
@ -283,6 +294,9 @@ func (s *CodeStore) Get(_ context.Context, hash string) (store.PhoneCode, bool,
defer s.mu.Unlock()
e, ok := s.m[hash]
if !ok || time.Now().After(e.expires) {
if ok {
s.deleteCodeLocked(hash, e.code)
}
return store.PhoneCode{}, false, nil
}
return e.code, true, nil
@ -293,6 +307,9 @@ func (s *CodeStore) Update(_ context.Context, hash string, code store.PhoneCode)
defer s.mu.Unlock()
e, ok := s.m[hash]
if !ok || time.Now().After(e.expires) {
if ok {
s.deleteCodeLocked(hash, e.code)
}
return nil
}
e.code = code
@ -302,7 +319,41 @@ func (s *CodeStore) Update(_ context.Context, hash string, code store.PhoneCode)
func (s *CodeStore) Del(_ context.Context, hash string) error {
s.mu.Lock()
delete(s.m, hash)
if e, ok := s.m[hash]; ok {
s.deleteCodeLocked(hash, e.code)
} else {
delete(s.m, hash)
}
s.mu.Unlock()
return nil
}
func (s *CodeStore) ConsumeScoped(_ context.Context, hash string, scope store.PhoneCodeScope) (store.PhoneCode, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if !scope.Valid() || s.scopes[scope] != hash {
return store.PhoneCode{}, false, nil
}
e, ok := s.m[hash]
if !ok || time.Now().After(e.expires) {
if ok {
s.deleteCodeLocked(hash, e.code)
} else {
delete(s.scopes, scope)
}
return store.PhoneCode{}, false, nil
}
if e.code.Scope() != scope {
return store.PhoneCode{}, false, nil
}
s.deleteCodeLocked(hash, e.code)
return e.code, true, nil
}
func (s *CodeStore) deleteCodeLocked(hash string, code store.PhoneCode) {
delete(s.m, hash)
scope := code.Scope()
if scope.Valid() && s.scopes[scope] == hash {
delete(s.scopes, scope)
}
}

View file

@ -198,9 +198,7 @@ func (s *ChannelStore) SetChannelVerified(_ context.Context, channelID int64, ve
}
func (s *ChannelStore) ResolvePublicChannelUsername(_ 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; no membership state is projected.
username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
if username == "" {
return domain.Channel{}, false, nil

View file

@ -0,0 +1,92 @@
package memory
import (
"context"
"sync"
"testing"
"time"
"telesrv/internal/store"
)
func TestCodeStoreScopedRotationAndSingleConsume(t *testing.T) {
ctx := context.Background()
codes := NewCodeStore()
rec := store.PhoneCode{
Phone: "15550015001",
Code: "12345",
Purpose: store.PhoneCodePurposeChangePhone,
UserID: 42,
AuthKeyID: [8]byte{1, 2, 3},
}
if err := codes.Set(ctx, "old-hash", rec, time.Minute); err != nil {
t.Fatalf("set old: %v", err)
}
if err := codes.Set(ctx, "new-hash", rec, time.Minute); err != nil {
t.Fatalf("rotate new: %v", err)
}
if _, found, err := codes.Get(ctx, "old-hash"); err != nil || found {
t.Fatalf("old hash found=%v err=%v", found, err)
}
const workers = 24
results := make(chan bool, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
_, found, err := codes.ConsumeScoped(ctx, "new-hash", rec.Scope())
if err != nil {
errs <- err
return
}
results <- found
}()
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("consume: %v", err)
}
foundCount := 0
for found := range results {
if found {
foundCount++
}
}
if foundCount != 1 {
t.Fatalf("successful consumes = %d, want 1", foundCount)
}
if _, found, _ := codes.Get(ctx, "new-hash"); found {
t.Fatal("consumed hash remains")
}
}
func TestCodeStoreScopedIsolation(t *testing.T) {
ctx := context.Background()
codes := NewCodeStore()
a := store.PhoneCode{Phone: "15550015002", Code: "12345", Purpose: store.PhoneCodePurposeChangePhone, UserID: 42, AuthKeyID: [8]byte{1}}
b := a
b.AuthKeyID = [8]byte{2}
if err := codes.Set(ctx, "hash-a", a, time.Minute); err != nil {
t.Fatal(err)
}
if err := codes.Set(ctx, "hash-b", b, time.Minute); err != nil {
t.Fatal(err)
}
if _, found, _ := codes.ConsumeScoped(ctx, "hash-a", b.Scope()); found {
t.Fatal("cross-scope consume succeeded")
}
if _, found, _ := codes.Get(ctx, "hash-a"); !found {
t.Fatal("cross-scope consume removed victim code")
}
if _, found, err := codes.ConsumeScoped(ctx, "hash-a", a.Scope()); err != nil || !found {
t.Fatalf("own-scope consume found=%v err=%v", found, err)
}
if _, found, _ := codes.Get(ctx, "hash-b"); !found {
t.Fatal("other scope was removed")
}
}

View file

@ -0,0 +1,72 @@
package memory
import (
"context"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// PhoneChangeStore 是测试用内存实现。用户唯一性在 UserStore 锁内维护;事件写入
// 共享 UpdateEventStore 后可由 updates.getDifference 重放。
type PhoneChangeStore struct {
users *UserStore
events store.UpdateEventStore
}
func NewPhoneChangeStore(users *UserStore, events store.UpdateEventStore) *PhoneChangeStore {
return &PhoneChangeStore{users: users, events: events}
}
func (*PhoneChangeStore) UsesReliableDispatch() bool { return false }
func (s *PhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChangeRequest) (domain.PhoneChangeResult, error) {
if s == nil || s.users == nil || req.UserID == 0 || !domain.ValidPhone(req.Phone) {
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberInvalid
}
s.users.mu.Lock()
u, ok := s.users.byID[req.UserID]
if !ok {
s.users.mu.Unlock()
return domain.PhoneChangeResult{}, domain.ErrUserNotFound
}
if u.Phone == req.Phone {
s.users.mu.Unlock()
return domain.PhoneChangeResult{User: u}, nil
}
for id, existing := range s.users.byID {
if id != req.UserID && existing.Phone == req.Phone {
s.users.mu.Unlock()
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied
}
}
currentPhone := u.Phone
u.Phone = req.Phone
s.users.byID[req.UserID] = u
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,
}
if s.events != nil {
var err error
event, err = s.events.AppendAllocated(ctx, req.UserID, event)
if err != nil {
// 保持内存替身与 PG 的 user+event 原子可见语义。
u.Phone = currentPhone
s.users.byID[req.UserID] = u
s.users.mu.Unlock()
return domain.PhoneChangeResult{}, err
}
}
s.users.mu.Unlock()
return domain.PhoneChangeResult{User: u, Event: event, Changed: true}, nil
}

View file

@ -0,0 +1,13 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// PhoneChangeStore 原子修改账号手机号并记录可恢复的 updateUserPhone 事件。
// 生产实现还必须在同一事务入 dispatch outbox。
type PhoneChangeStore interface {
ChangePhone(ctx context.Context, req domain.PhoneChangeRequest) (domain.PhoneChangeResult, error)
}

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),

View file

@ -2,6 +2,8 @@ package redisstore
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@ -22,14 +24,80 @@ func NewCodeStore(c *redis.Client) *CodeStore {
return &CodeStore{c: c}
}
func codeKey(hash string) string { return "phonecode:" + hash }
const codeKeyPrefix = "phonecode:"
func codeKey(hash string) string { return codeKeyPrefix + hash }
func codeScopeKey(scope store.PhoneCodeScope) string {
// 不把手机号/auth key 明文放进 Redis keyJSON 仅作为稳定的长度分隔编码输入。
raw, _ := json.Marshal(scope)
digest := sha256.Sum256(raw)
return "phonecodescope:" + hex.EncodeToString(digest[:])
}
const rotateScopedCodeScript = `
local old_hash = redis.call('GET', KEYS[2])
if old_hash and old_hash ~= ARGV[1] then
redis.call('DEL', ARGV[4] .. old_hash)
end
local ttl_ms = tonumber(ARGV[3])
if ttl_ms and ttl_ms > 0 then
redis.call('PSETEX', KEYS[1], ttl_ms, ARGV[2])
redis.call('PSETEX', KEYS[2], ttl_ms, ARGV[1])
else
redis.call('SET', KEYS[1], ARGV[2])
redis.call('SET', KEYS[2], ARGV[1])
end
return 1
`
const deleteScopedCodeScript = `
redis.call('DEL', KEYS[1])
if redis.call('GET', KEYS[2]) == ARGV[1] then
redis.call('DEL', KEYS[2])
end
return 1
`
const consumeScopedCodeScript = `
if redis.call('GET', KEYS[2]) ~= ARGV[1] then
return false
end
local raw = redis.call('GET', KEYS[1])
if not raw then
redis.call('DEL', KEYS[2])
return false
end
redis.call('DEL', KEYS[1])
redis.call('DEL', KEYS[2])
return raw
`
func (s *CodeStore) Set(ctx context.Context, hash string, code store.PhoneCode, ttl time.Duration) error {
v, err := json.Marshal(code)
if err != nil {
return fmt.Errorf("marshal phone code: %w", err)
}
if err := s.c.Set(ctx, codeKey(hash), v, ttl).Err(); err != nil {
scope := code.Scope()
if !scope.Valid() {
if err := s.c.Set(ctx, codeKey(hash), v, ttl).Err(); err != nil {
return fmt.Errorf("redis set phone code: %w", err)
}
return nil
}
ttlMillis := ttl.Milliseconds()
if ttl > 0 && ttlMillis == 0 {
ttlMillis = 1
}
if err := s.c.Eval(
ctx,
rotateScopedCodeScript,
[]string{codeKey(hash), codeScopeKey(scope)},
hash,
string(v),
ttlMillis,
codeKeyPrefix,
).Err(); err != nil {
return fmt.Errorf("redis set phone code: %w", err)
}
return nil
@ -52,7 +120,7 @@ func (s *CodeStore) Get(ctx context.Context, hash string) (store.PhoneCode, bool
func (s *CodeStore) Update(ctx context.Context, hash string, code store.PhoneCode) error {
key := codeKey(hash)
ttl, err := s.c.TTL(ctx, key).Result()
ttl, err := s.c.PTTL(ctx, key).Result()
if err != nil {
return fmt.Errorf("redis ttl phone code: %w", err)
}
@ -70,5 +138,57 @@ func (s *CodeStore) Update(ctx context.Context, hash string, code store.PhoneCod
}
func (s *CodeStore) Del(ctx context.Context, hash string) error {
return s.c.Del(ctx, codeKey(hash)).Err()
key := codeKey(hash)
raw, err := s.c.Get(ctx, key).Bytes()
if err != nil {
if errors.Is(err, redis.Nil) {
return s.c.Del(ctx, key).Err()
}
return fmt.Errorf("redis get phone code for delete: %w", err)
}
var code store.PhoneCode
if err := json.Unmarshal(raw, &code); err != nil {
return fmt.Errorf("unmarshal phone code for delete: %w", err)
}
scope := code.Scope()
if !scope.Valid() {
return s.c.Del(ctx, key).Err()
}
if err := s.c.Eval(ctx, deleteScopedCodeScript, []string{key, codeScopeKey(scope)}, hash).Err(); err != nil {
return fmt.Errorf("redis delete scoped phone code: %w", err)
}
return nil
}
func (s *CodeStore) ConsumeScoped(ctx context.Context, hash string, scope store.PhoneCodeScope) (store.PhoneCode, bool, error) {
if !scope.Valid() {
return store.PhoneCode{}, false, nil
}
result, err := s.c.Eval(
ctx,
consumeScopedCodeScript,
[]string{codeKey(hash), codeScopeKey(scope)},
hash,
).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return store.PhoneCode{}, false, nil
}
return store.PhoneCode{}, false, fmt.Errorf("redis consume scoped phone code: %w", err)
}
if result == nil {
return store.PhoneCode{}, false, nil
}
raw, ok := result.(string)
if !ok {
return store.PhoneCode{}, false, fmt.Errorf("redis consume scoped phone code: unexpected result %T", result)
}
var code store.PhoneCode
if err := json.Unmarshal([]byte(raw), &code); err != nil {
return store.PhoneCode{}, false, fmt.Errorf("unmarshal consumed phone code: %w", err)
}
if code.Scope() != scope {
return store.PhoneCode{}, false, fmt.Errorf("consumed phone code scope mismatch")
}
return code, true, nil
}

View file

@ -0,0 +1,83 @@
package redisstore
import (
"context"
"fmt"
"os"
"sync"
"testing"
"time"
"telesrv/internal/store"
)
func TestRedisCodeStoreScopedRotationAndSingleConsume(t *testing.T) {
addr := os.Getenv("TELESRV_TEST_REDIS_ADDR")
if addr == "" {
t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test")
}
ctx := context.Background()
c, err := Open(ctx, addr, "", 0)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
suffix := time.Now().UnixNano()
oldHash := fmt.Sprintf("scope-old-%d", suffix)
newHash := fmt.Sprintf("scope-new-%d", suffix)
rec := store.PhoneCode{
Phone: fmt.Sprintf("1555%d", suffix),
Code: "12345",
Purpose: store.PhoneCodePurposeChangePhone,
UserID: suffix,
AuthKeyID: [8]byte{1, 2, 3, 4},
}
scopeKey := codeScopeKey(rec.Scope())
t.Cleanup(func() { _ = c.Del(ctx, codeKey(oldHash), codeKey(newHash), scopeKey).Err() })
codes := NewCodeStore(c)
if err := codes.Set(ctx, oldHash, rec, time.Minute); err != nil {
t.Fatalf("set old: %v", err)
}
if err := codes.Set(ctx, newHash, rec, time.Minute); err != nil {
t.Fatalf("rotate new: %v", err)
}
if _, found, err := codes.Get(ctx, oldHash); err != nil || found {
t.Fatalf("old hash found=%v err=%v", found, err)
}
const workers = 24
results := make(chan bool, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
_, found, err := codes.ConsumeScoped(ctx, newHash, rec.Scope())
if err != nil {
errs <- err
return
}
results <- found
}()
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("consume: %v", err)
}
foundCount := 0
for found := range results {
if found {
foundCount++
}
}
if foundCount != 1 {
t.Fatalf("successful consumes = %d, want 1", foundCount)
}
if exists, err := c.Exists(ctx, codeKey(newHash), scopeKey).Result(); err != nil || exists != 0 {
t.Fatalf("remaining redis keys=%d err=%v", exists, err)
}
}