feat: sync public links and phone change updates
This commit is contained in:
parent
41c7f1d018
commit
da04c0fa6a
53 changed files with 3029 additions and 111 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
92
internal/store/memory/code_test.go
Normal file
92
internal/store/memory/code_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
72
internal/store/memory/phone_change.go
Normal file
72
internal/store/memory/phone_change.go
Normal 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
|
||||
}
|
||||
13
internal/store/phone_change.go
Normal file
13
internal/store/phone_change.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
112
internal/store/postgres/phone_change.go
Normal file
112
internal/store/postgres/phone_change.go
Normal 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(¤tPhone); 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
|
||||
}
|
||||
86
internal/store/postgres/phone_change_integration_test.go
Normal file
86
internal/store/postgres/phone_change_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 key;JSON 仅作为稳定的长度分隔编码输入。
|
||||
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
|
||||
}
|
||||
|
|
|
|||
83
internal/store/redisstore/code_integration_test.go
Normal file
83
internal/store/redisstore/code_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue