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

@ -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
}