Merge remote-tracking branch 'upstream/main' into dev
This commit is contained in:
commit
6b29556ef8
836 changed files with 1598388 additions and 64684 deletions
|
|
@ -4,44 +4,96 @@ import (
|
|||
"context"
|
||||
"encoding/binary"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"time"
|
||||
)
|
||||
|
||||
type authKeyState struct {
|
||||
mu sync.RWMutex
|
||||
keys map[[8]byte]store.AuthKeyData
|
||||
bindings map[[8]byte]domain.TempAuthKeyBinding
|
||||
sessionLayers map[authKeySessionLayerKey]store.AuthKeySessionLayer
|
||||
authorizationMirrors map[*AuthorizationStore]struct{}
|
||||
nextLayerObservation int64
|
||||
}
|
||||
|
||||
// AuthKeyStore 是 store.AuthKeyStore 的内存实现。
|
||||
type AuthKeyStore struct {
|
||||
mu sync.RWMutex
|
||||
keys map[[8]byte]store.AuthKeyData
|
||||
state *authKeyState
|
||||
}
|
||||
|
||||
// NewAuthKeyStore 创建内存 AuthKeyStore。
|
||||
func NewAuthKeyStore() *AuthKeyStore {
|
||||
return &AuthKeyStore{keys: make(map[[8]byte]store.AuthKeyData)}
|
||||
return &AuthKeyStore{state: &authKeyState{
|
||||
keys: make(map[[8]byte]store.AuthKeyData),
|
||||
bindings: make(map[[8]byte]domain.TempAuthKeyBinding),
|
||||
sessionLayers: make(map[authKeySessionLayerKey]store.AuthKeySessionLayer),
|
||||
authorizationMirrors: make(map[*AuthorizationStore]struct{}),
|
||||
}}
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) Save(_ context.Context, k store.AuthKeyData) error {
|
||||
s.mu.Lock()
|
||||
s.keys[k.ID] = k
|
||||
s.mu.Unlock()
|
||||
if !store.ValidNewAuthKeyProtocolExpiry(k.ExpiresAt) {
|
||||
return store.ErrInvalidAuthKeyProtocolExpiry
|
||||
}
|
||||
s.state.mu.Lock()
|
||||
if current, ok := s.state.keys[k.ID]; ok {
|
||||
if current.Value != k.Value || current.ExpiresAt != k.ExpiresAt {
|
||||
s.state.mu.Unlock()
|
||||
return store.ErrAuthKeyProtocolMetadataConflict
|
||||
}
|
||||
// Save is the idempotent protocol-key upsert. Match PostgreSQL's
|
||||
// ON CONFLICT behavior: a repeated handshake may refresh server_salt,
|
||||
// but must never erase Layer/client metadata recorded after the first
|
||||
// insert merely because the protocol write carries zero-value metadata.
|
||||
if k.CreatedAt == 0 {
|
||||
k.CreatedAt = current.CreatedAt
|
||||
}
|
||||
k.Layer = current.Layer
|
||||
k.LayerObservationID = current.LayerObservationID
|
||||
k.DeviceModel = current.DeviceModel
|
||||
k.Platform = current.Platform
|
||||
k.SystemVersion = current.SystemVersion
|
||||
k.APIID = current.APIID
|
||||
k.AppVersion = current.AppVersion
|
||||
}
|
||||
s.state.keys[k.ID] = k
|
||||
s.state.mirrorAuthorizationLayersLocked([][8]byte{k.ID}, k.Layer)
|
||||
// Tests may restore a store snapshot carrying an already-issued durable
|
||||
// observation. Keep the in-memory sequence above that watermark so the next
|
||||
// AdvanceSessionLayer has the same monotonic ordering as PostgreSQL's
|
||||
// sequence after a restart/fixture restore.
|
||||
if k.LayerObservationID > s.state.nextLayerObservation {
|
||||
s.state.nextLayerObservation = k.LayerObservationID
|
||||
}
|
||||
s.state.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) Get(_ context.Context, id [8]byte) (store.AuthKeyData, bool, error) {
|
||||
s.mu.RLock()
|
||||
k, ok := s.keys[id]
|
||||
s.mu.RUnlock()
|
||||
s.state.mu.RLock()
|
||||
k, ok := s.state.keys[id]
|
||||
s.state.mu.RUnlock()
|
||||
return k, ok, nil
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) UpdateClientInfo(_ context.Context, id [8]byte, info store.AuthKeyClientInfo) error {
|
||||
s.mu.Lock()
|
||||
k, ok := s.keys[id]
|
||||
if ok {
|
||||
mergeAuthKeyClientInfo(&k, info)
|
||||
s.keys[id] = k
|
||||
s.state.mu.Lock()
|
||||
k, ok := s.state.keys[id]
|
||||
if !ok {
|
||||
s.state.mu.Unlock()
|
||||
return store.ErrAuthKeyNotFound
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if info.Layer > 0 && k.LayerObservationID > 0 && info.Layer != k.Layer {
|
||||
s.state.mu.Unlock()
|
||||
return store.ErrAuthKeySessionLayerConflict
|
||||
}
|
||||
mergeAuthKeyClientInfo(&k, info)
|
||||
s.state.keys[id] = k
|
||||
s.state.mirrorAuthorizationLayersLocked([][8]byte{id}, k.Layer)
|
||||
s.state.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -66,36 +118,123 @@ func mergeAuthKeyClientInfo(k *store.AuthKeyData, info store.AuthKeyClientInfo)
|
|||
}
|
||||
}
|
||||
|
||||
// mirrorAuthorizationLayersLocked updates the materialized authorization view
|
||||
// at the same write boundary as auth_keys. authKeyState.mu must be held; the
|
||||
// only cross-object lock order is auth-key state -> authorization mirror.
|
||||
func (s *authKeyState) mirrorAuthorizationLayersLocked(ids [][8]byte, layer int) {
|
||||
for mirror := range s.authorizationMirrors {
|
||||
mirror.mu.Lock()
|
||||
for _, id := range ids {
|
||||
if a, found := mirror.m[id]; found {
|
||||
a.Layer = layer
|
||||
mirror.m[id] = a
|
||||
}
|
||||
}
|
||||
mirror.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *authKeyState) deleteAuthorizationMirrorsLocked(ids [][8]byte) {
|
||||
s.deleteAuthorizationMirrorsWithHeldLocked(ids, nil)
|
||||
}
|
||||
|
||||
func (s *authKeyState) deleteAuthorizationMirrorsWithHeldLocked(ids [][8]byte, held *AuthorizationStore) {
|
||||
for mirror := range s.authorizationMirrors {
|
||||
if mirror != held {
|
||||
mirror.mu.Lock()
|
||||
}
|
||||
for _, id := range ids {
|
||||
delete(mirror.m, id)
|
||||
}
|
||||
if mirror != held {
|
||||
mirror.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) Delete(_ context.Context, id [8]byte) error {
|
||||
s.mu.Lock()
|
||||
delete(s.keys, id)
|
||||
s.mu.Unlock()
|
||||
s.state.mu.Lock()
|
||||
deleted := s.state.deleteProtocolAuthKeyLocked(id)
|
||||
s.state.deleteAuthorizationMirrorsLocked(deleted)
|
||||
s.state.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *authKeyState) deleteProtocolAuthKeyLocked(id [8]byte) [][8]byte {
|
||||
deleting, exists := s.keys[id]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
deleted := make([][8]byte, 0, 2)
|
||||
if deleting.ExpiresAt > 0 {
|
||||
delete(s.bindings, id)
|
||||
} else {
|
||||
permID := int64(binary.LittleEndian.Uint64(id[:]))
|
||||
for tempID, binding := range s.bindings {
|
||||
if binding.PermAuthKeyID != permID {
|
||||
continue
|
||||
}
|
||||
delete(s.bindings, tempID)
|
||||
delete(s.keys, tempID)
|
||||
s.deleteSessionLayersLocked(tempID)
|
||||
deleted = append(deleted, tempID)
|
||||
}
|
||||
}
|
||||
delete(s.keys, id)
|
||||
s.deleteSessionLayersLocked(id)
|
||||
return append(deleted, id)
|
||||
}
|
||||
|
||||
// TempAuthKeyBindingStore 是 store.TempAuthKeyBindingStore 的内存实现。
|
||||
type TempAuthKeyBindingStore struct {
|
||||
mu sync.RWMutex
|
||||
m map[[8]byte]domain.TempAuthKeyBinding
|
||||
state *authKeyState
|
||||
}
|
||||
|
||||
// NewTempAuthKeyBindingStore 创建内存 TempAuthKeyBindingStore。
|
||||
func NewTempAuthKeyBindingStore() *TempAuthKeyBindingStore {
|
||||
return &TempAuthKeyBindingStore{m: make(map[[8]byte]domain.TempAuthKeyBinding)}
|
||||
func NewTempAuthKeyBindingStore(authKeys *AuthKeyStore) *TempAuthKeyBindingStore {
|
||||
if authKeys == nil {
|
||||
panic("memory.NewTempAuthKeyBindingStore requires a non-nil AuthKeyStore")
|
||||
}
|
||||
return &TempAuthKeyBindingStore{state: authKeys.state}
|
||||
}
|
||||
|
||||
func (s *TempAuthKeyBindingStore) Save(_ context.Context, b domain.TempAuthKeyBinding) error {
|
||||
b.EncryptedMessage = append([]byte(nil), b.EncryptedMessage...)
|
||||
s.mu.Lock()
|
||||
s.m[b.TempAuthKeyID] = b
|
||||
s.mu.Unlock()
|
||||
s.state.mu.Lock()
|
||||
defer s.state.mu.Unlock()
|
||||
if current, ok := s.state.bindings[b.TempAuthKeyID]; ok && current.PermAuthKeyID != b.PermAuthKeyID {
|
||||
return store.ErrTempAuthKeyAlreadyBound
|
||||
}
|
||||
temp, tempFound := s.state.keys[b.TempAuthKeyID]
|
||||
var permID [8]byte
|
||||
binary.LittleEndian.PutUint64(permID[:], uint64(b.PermAuthKeyID))
|
||||
perm, permFound := s.state.keys[permID]
|
||||
if !tempFound || !permFound || temp.ExpiresAt <= 0 || perm.ExpiresAt != 0 || b.ExpiresAt != temp.ExpiresAt {
|
||||
return store.ErrAuthKeyBindingInvalid
|
||||
}
|
||||
// Binding and Layer-default normalization are one state transition. Exact
|
||||
// session evidence remains keyed by the raw temp key; only the inherited
|
||||
// default follows the globally ordered observation.
|
||||
layer, observationID, err := store.MergeAuthKeyLayerObservations(
|
||||
temp.Layer, temp.LayerObservationID,
|
||||
perm.Layer, perm.LayerObservationID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temp.Layer, temp.LayerObservationID = layer, observationID
|
||||
perm.Layer, perm.LayerObservationID = layer, observationID
|
||||
s.state.keys[b.TempAuthKeyID] = temp
|
||||
s.state.keys[permID] = perm
|
||||
s.state.bindings[b.TempAuthKeyID] = b
|
||||
s.state.mirrorAuthorizationLayersLocked([][8]byte{b.TempAuthKeyID, permID}, layer)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TempAuthKeyBindingStore) GetByTemp(_ context.Context, tempAuthKeyID [8]byte) (domain.TempAuthKeyBinding, bool, error) {
|
||||
s.mu.RLock()
|
||||
b, ok := s.m[tempAuthKeyID]
|
||||
s.mu.RUnlock()
|
||||
s.state.mu.RLock()
|
||||
b, ok := s.state.bindings[tempAuthKeyID]
|
||||
s.state.mu.RUnlock()
|
||||
if !ok {
|
||||
return domain.TempAuthKeyBinding{}, false, nil
|
||||
}
|
||||
|
|
@ -107,25 +246,31 @@ func (s *TempAuthKeyBindingStore) DeleteExpired(_ context.Context, expiredBefore
|
|||
if limit <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.state.mu.Lock()
|
||||
defer s.state.mu.Unlock()
|
||||
deleted := 0
|
||||
for id, b := range s.m {
|
||||
for id, key := range s.state.keys {
|
||||
if deleted >= limit {
|
||||
break
|
||||
}
|
||||
if int64(b.ExpiresAt) < expiredBefore {
|
||||
delete(s.m, id)
|
||||
deleted++
|
||||
if key.ExpiresAt <= 0 || int64(key.ExpiresAt) >= expiredBefore {
|
||||
continue
|
||||
}
|
||||
delete(s.state.bindings, id)
|
||||
delete(s.state.keys, id)
|
||||
s.state.deleteSessionLayersLocked(id)
|
||||
s.state.deleteAuthorizationMirrorsLocked([][8]byte{id})
|
||||
deleted++
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// AuthorizationStore 是 store.AuthorizationStore 的内存实现。
|
||||
type AuthorizationStore struct {
|
||||
mu sync.RWMutex
|
||||
m map[[8]byte]domain.Authorization
|
||||
linkMu sync.RWMutex
|
||||
authKeys *authKeyState
|
||||
mu sync.RWMutex
|
||||
m map[[8]byte]domain.Authorization
|
||||
}
|
||||
|
||||
// NewAuthorizationStore 创建内存 AuthorizationStore。
|
||||
|
|
@ -133,6 +278,42 @@ func NewAuthorizationStore() *AuthorizationStore {
|
|||
return &AuthorizationStore{m: make(map[[8]byte]domain.Authorization)}
|
||||
}
|
||||
|
||||
// LinkAuthKeyAuthority connects the test/dev in-memory projection to the same
|
||||
// auth-key state. PostgreSQL performs the equivalent projection updates inside
|
||||
// its write transactions. Existing standalone AuthorizationStore construction
|
||||
// remains valid for tests that intentionally omit protocol keys.
|
||||
func (s *AuthorizationStore) LinkAuthKeyAuthority(keys store.AuthKeyStore) {
|
||||
authKeys, ok := keys.(*AuthKeyStore)
|
||||
if !ok || authKeys == nil || authKeys.state == nil {
|
||||
return
|
||||
}
|
||||
s.linkMu.Lock()
|
||||
defer s.linkMu.Unlock()
|
||||
if s.authKeys == authKeys.state {
|
||||
return
|
||||
}
|
||||
if s.authKeys != nil {
|
||||
// A projection has one authoritative primary. Constructors do not
|
||||
// return errors, so preserve the first explicit composition.
|
||||
return
|
||||
}
|
||||
authKeys.state.mu.Lock()
|
||||
s.mu.Lock()
|
||||
for id, a := range s.m {
|
||||
if key, found := authKeys.state.keys[id]; found {
|
||||
a.Layer = key.Layer
|
||||
s.m[id] = a
|
||||
}
|
||||
}
|
||||
if authKeys.state.authorizationMirrors == nil {
|
||||
authKeys.state.authorizationMirrors = make(map[*AuthorizationStore]struct{})
|
||||
}
|
||||
authKeys.state.authorizationMirrors[s] = struct{}{}
|
||||
s.authKeys = authKeys.state
|
||||
s.mu.Unlock()
|
||||
authKeys.state.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) Bind(_ context.Context, a domain.Authorization) error {
|
||||
now := time.Now()
|
||||
if a.Hash == 0 {
|
||||
|
|
@ -142,13 +323,40 @@ func (s *AuthorizationStore) Bind(_ context.Context, a domain.Authorization) err
|
|||
a.CreatedAt = now
|
||||
}
|
||||
a.ActiveAt = now
|
||||
s.linkMu.RLock()
|
||||
if s.authKeys != nil {
|
||||
s.authKeys.mu.RLock()
|
||||
key, found := s.authKeys.keys[a.AuthKeyID]
|
||||
if !found {
|
||||
s.authKeys.mu.RUnlock()
|
||||
s.linkMu.RUnlock()
|
||||
return store.ErrAuthKeyNotFound
|
||||
}
|
||||
if key.ExpiresAt != 0 {
|
||||
s.authKeys.mu.RUnlock()
|
||||
s.linkMu.RUnlock()
|
||||
return store.ErrAuthKeyNotPermanent
|
||||
}
|
||||
a.Layer = key.Layer
|
||||
s.mu.Lock()
|
||||
s.bindLocked(a)
|
||||
s.mu.Unlock()
|
||||
s.authKeys.mu.RUnlock()
|
||||
s.linkMu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
s.linkMu.RUnlock()
|
||||
s.mu.Lock()
|
||||
s.bindLocked(a)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) bindLocked(a domain.Authorization) {
|
||||
if existing, ok := s.m[a.AuthKeyID]; ok && !existing.CreatedAt.IsZero() {
|
||||
a.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
s.m[a.AuthKeyID] = a
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) ByAuthKey(_ context.Context, id [8]byte) (domain.Authorization, bool, error) {
|
||||
|
|
@ -158,46 +366,57 @@ func (s *AuthorizationStore) ByAuthKey(_ context.Context, id [8]byte) (domain.Au
|
|||
return a, ok, nil
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) UpdateLayer(_ context.Context, id [8]byte, layer int) error {
|
||||
if layer <= 0 {
|
||||
func (s *AuthorizationStore) UpdateClientInfo(_ context.Context, id [8]byte, info domain.AuthKeyClientInfo) error {
|
||||
s.linkMu.RLock()
|
||||
if s.authKeys != nil {
|
||||
s.authKeys.mu.RLock()
|
||||
key, found := s.authKeys.keys[id]
|
||||
if !found {
|
||||
s.authKeys.mu.RUnlock()
|
||||
s.linkMu.RUnlock()
|
||||
return store.ErrAuthKeyNotFound
|
||||
}
|
||||
s.mu.Lock()
|
||||
if a, ok := s.m[id]; ok {
|
||||
mergeAuthorizationClientInfo(&a, info)
|
||||
a.Layer = key.Layer
|
||||
s.m[id] = a
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.authKeys.mu.RUnlock()
|
||||
s.linkMu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
s.linkMu.RUnlock()
|
||||
s.mu.Lock()
|
||||
if a, ok := s.m[id]; ok {
|
||||
a.Layer = layer
|
||||
a.ActiveAt = time.Now()
|
||||
mergeAuthorizationClientInfo(&a, info)
|
||||
s.m[id] = a
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) UpdateClientInfo(_ context.Context, id [8]byte, info domain.AuthKeyClientInfo) error {
|
||||
s.mu.Lock()
|
||||
if a, ok := s.m[id]; ok {
|
||||
if info.Layer > 0 {
|
||||
a.Layer = info.Layer
|
||||
}
|
||||
if info.DeviceModel != "" {
|
||||
a.DeviceModel = info.DeviceModel
|
||||
}
|
||||
if info.Platform != "" {
|
||||
a.Platform = info.Platform
|
||||
}
|
||||
if info.SystemVersion != "" {
|
||||
a.SystemVersion = info.SystemVersion
|
||||
}
|
||||
if info.APIID != 0 {
|
||||
a.APIID = info.APIID
|
||||
}
|
||||
if info.AppVersion != "" {
|
||||
a.AppVersion = info.AppVersion
|
||||
}
|
||||
a.ActiveAt = time.Now()
|
||||
s.m[id] = a
|
||||
func mergeAuthorizationClientInfo(a *domain.Authorization, info domain.AuthKeyClientInfo) {
|
||||
if info.Layer > 0 {
|
||||
a.Layer = info.Layer
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
if info.DeviceModel != "" {
|
||||
a.DeviceModel = info.DeviceModel
|
||||
}
|
||||
if info.Platform != "" {
|
||||
a.Platform = info.Platform
|
||||
}
|
||||
if info.SystemVersion != "" {
|
||||
a.SystemVersion = info.SystemVersion
|
||||
}
|
||||
if info.APIID != 0 {
|
||||
a.APIID = info.APIID
|
||||
}
|
||||
if info.AppVersion != "" {
|
||||
a.AppVersion = info.AppVersion
|
||||
}
|
||||
a.ActiveAt = time.Now()
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) MarkPasswordPassed(_ context.Context, id [8]byte) error {
|
||||
|
|
@ -242,6 +461,39 @@ func (s *AuthorizationStore) DeleteByHash(_ context.Context, userID, hash int64)
|
|||
return domain.Authorization{}, false, nil
|
||||
}
|
||||
|
||||
// RevokeByHash mirrors PostgreSQL's protocol-key revocation boundary when this
|
||||
// authorization projection is linked to an in-memory auth-key authority.
|
||||
func (s *AuthorizationStore) RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
|
||||
s.linkMu.RLock()
|
||||
defer s.linkMu.RUnlock()
|
||||
if s.authKeys == nil {
|
||||
return s.DeleteByHash(ctx, userID, hash)
|
||||
}
|
||||
s.authKeys.mu.Lock()
|
||||
s.mu.Lock()
|
||||
var (
|
||||
targetID [8]byte
|
||||
target domain.Authorization
|
||||
found bool
|
||||
)
|
||||
for id, a := range s.m {
|
||||
if a.UserID == userID && a.Hash == hash {
|
||||
targetID, target, found = id, a, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
s.mu.Unlock()
|
||||
s.authKeys.mu.Unlock()
|
||||
return domain.Authorization{}, false, nil
|
||||
}
|
||||
deletedIDs := s.authKeys.deleteProtocolAuthKeyLocked(targetID)
|
||||
s.authKeys.deleteAuthorizationMirrorsWithHeldLocked(deletedIDs, s)
|
||||
s.mu.Unlock()
|
||||
s.authKeys.mu.Unlock()
|
||||
return target, true, nil
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) DeleteByUserExcept(_ context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
@ -256,6 +508,32 @@ func (s *AuthorizationStore) DeleteByUserExcept(_ context.Context, userID int64,
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) RevokeByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
|
||||
s.linkMu.RLock()
|
||||
defer s.linkMu.RUnlock()
|
||||
if s.authKeys == nil {
|
||||
return s.DeleteByUserExcept(ctx, userID, keepAuthKeyID)
|
||||
}
|
||||
s.authKeys.mu.Lock()
|
||||
s.mu.Lock()
|
||||
out := make([]domain.Authorization, 0)
|
||||
targets := make([][8]byte, 0)
|
||||
for id, a := range s.m {
|
||||
if a.UserID == userID && id != keepAuthKeyID {
|
||||
out = append(out, a)
|
||||
targets = append(targets, id)
|
||||
}
|
||||
}
|
||||
deletedIDs := make([][8]byte, 0, len(targets))
|
||||
for _, id := range targets {
|
||||
deletedIDs = append(deletedIDs, s.authKeys.deleteProtocolAuthKeyLocked(id)...)
|
||||
}
|
||||
s.authKeys.deleteAuthorizationMirrorsWithHeldLocked(deletedIDs, s)
|
||||
s.mu.Unlock()
|
||||
s.authKeys.mu.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CodeStore 是 store.CodeStore 的内存实现(带 TTL)。
|
||||
type CodeStore struct {
|
||||
mu sync.Mutex
|
||||
|
|
|
|||
461
internal/store/memory/auth_test.go
Normal file
461
internal/store/memory/auth_test.go
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestAuthKeyStorePreservesProtocolExpiry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore()
|
||||
want := store.AuthKeyData{
|
||||
ID: [8]byte{1, 2, 3, 4, 5, 6, 7, 8},
|
||||
ServerSalt: 42,
|
||||
ExpiresAt: 1_799_999_999,
|
||||
}
|
||||
want.Value[0] = 0xaa
|
||||
want.Value[len(want.Value)-1] = 0x55
|
||||
|
||||
if err := keys.Save(ctx, want); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
got, found, err := keys.Get(ctx, want.ID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("get: found=%v err=%v", found, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("round trip mismatch: got %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
conflicting := want
|
||||
conflicting.ExpiresAt++
|
||||
if err := keys.Save(ctx, conflicting); !errors.Is(err, store.ErrAuthKeyProtocolMetadataConflict) {
|
||||
t.Fatalf("reclassify auth key error = %v, want %v", err, store.ErrAuthKeyProtocolMetadataConflict)
|
||||
}
|
||||
got, found, err = keys.Get(ctx, want.ID)
|
||||
if err != nil || !found || got != want {
|
||||
t.Fatalf("auth key changed after rejected reclassification: got=%+v found=%v err=%v", got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthKeyStoreProtocolRetryPreservesClientLayerMetadata(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore()
|
||||
id := memoryAuthKeyID(17)
|
||||
key := store.AuthKeyData{ID: id, ServerSalt: 10, CreatedAt: 11}
|
||||
key.Value[0] = 1
|
||||
if err := keys.Save(ctx, key); err != nil {
|
||||
t.Fatalf("save auth key: %v", err)
|
||||
}
|
||||
if err := keys.UpdateClientInfo(ctx, id, store.AuthKeyClientInfo{
|
||||
Layer: 227, DeviceModel: "Desktop", Platform: "tdesktop",
|
||||
SystemVersion: "Windows", APIID: 2040, AppVersion: "6.2",
|
||||
}); err != nil {
|
||||
t.Fatalf("update client info: %v", err)
|
||||
}
|
||||
|
||||
retry := key
|
||||
retry.ServerSalt = 20
|
||||
retry.CreatedAt = 0
|
||||
if err := keys.Save(ctx, retry); err != nil {
|
||||
t.Fatalf("retry protocol save: %v", err)
|
||||
}
|
||||
got, found, err := keys.Get(ctx, id)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("get auth key: found=%v err=%v", found, err)
|
||||
}
|
||||
if got.ServerSalt != 20 || got.CreatedAt != 11 {
|
||||
t.Fatalf("protocol fields = salt:%d created:%d, want 20/11", got.ServerSalt, got.CreatedAt)
|
||||
}
|
||||
if got.Layer != 227 || got.DeviceModel != "Desktop" || got.Platform != "tdesktop" ||
|
||||
got.SystemVersion != "Windows" || got.APIID != 2040 || got.AppVersion != "6.2" {
|
||||
t.Fatalf("client metadata was erased by protocol retry: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthKeyStoreUpdateClientInfoRejectsMissingPrimary(t *testing.T) {
|
||||
keys := NewAuthKeyStore()
|
||||
err := keys.UpdateClientInfo(context.Background(), memoryAuthKeyID(18), store.AuthKeyClientInfo{Layer: 227})
|
||||
if !errors.Is(err, store.ErrAuthKeyNotFound) {
|
||||
t.Fatalf("missing primary update error = %v, want %v", err, store.ErrAuthKeyNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthKeyStoreUpdateClientInfoProtectsObservedLayer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore()
|
||||
id := memoryAuthKeyID(19)
|
||||
want := store.AuthKeyData{
|
||||
ID: id, Layer: 227, LayerObservationID: 91,
|
||||
DeviceModel: "before", Platform: "tdesktop",
|
||||
}
|
||||
if err := keys.Save(ctx, want); err != nil {
|
||||
t.Fatalf("save observed auth key: %v", err)
|
||||
}
|
||||
|
||||
err := keys.UpdateClientInfo(ctx, id, store.AuthKeyClientInfo{
|
||||
Layer: 220, DeviceModel: "must-not-merge", AppVersion: "must-not-merge",
|
||||
})
|
||||
if !errors.Is(err, store.ErrAuthKeySessionLayerConflict) {
|
||||
t.Fatalf("conflicting layer update error = %v, want %v", err, store.ErrAuthKeySessionLayerConflict)
|
||||
}
|
||||
got, found, err := keys.Get(ctx, id)
|
||||
if err != nil || !found || got != want {
|
||||
t.Fatalf("auth key changed after layer conflict: got=%+v found=%v err=%v, want=%+v", got, found, err, want)
|
||||
}
|
||||
|
||||
if err := keys.UpdateClientInfo(ctx, id, store.AuthKeyClientInfo{
|
||||
Layer: 227, DeviceModel: "same-layer", AppVersion: "1.0",
|
||||
}); err != nil {
|
||||
t.Fatalf("same observed layer metadata merge: %v", err)
|
||||
}
|
||||
if err := keys.UpdateClientInfo(ctx, id, store.AuthKeyClientInfo{
|
||||
Layer: 0, Platform: "windows", SystemVersion: "11",
|
||||
}); err != nil {
|
||||
t.Fatalf("layerless metadata merge: %v", err)
|
||||
}
|
||||
got, found, err = keys.Get(ctx, id)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("get merged auth key: found=%v err=%v", found, err)
|
||||
}
|
||||
if got.Layer != 227 || got.LayerObservationID != 91 || got.DeviceModel != "same-layer" ||
|
||||
got.Platform != "windows" || got.SystemVersion != "11" || got.AppVersion != "1.0" {
|
||||
t.Fatalf("guarded metadata merge = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTempAuthKeyBindingStoreMergesLayerObservations(t *testing.T) {
|
||||
const handshakeExpiry = 1_800_000_000
|
||||
tests := []struct {
|
||||
name string
|
||||
tempLayer int
|
||||
tempObs int64
|
||||
permLayer int
|
||||
permObs int64
|
||||
wantLayer int
|
||||
wantObs int64
|
||||
wantErr error
|
||||
}{
|
||||
{name: "temporary newer", tempLayer: 227, tempObs: 20, permLayer: 220, permObs: 10, wantLayer: 227, wantObs: 20},
|
||||
{name: "permanent newer", tempLayer: 220, tempObs: 10, permLayer: 227, permObs: 20, wantLayer: 227, wantObs: 20},
|
||||
{name: "equal ordered same layer", tempLayer: 225, tempObs: 30, permLayer: 225, permObs: 30, wantLayer: 225, wantObs: 30},
|
||||
{name: "equal ordered conflict", tempLayer: 220, tempObs: 30, permLayer: 227, permObs: 30, wantErr: store.ErrAuthKeySessionLayerConflict},
|
||||
{name: "legacy permanent wins", tempLayer: 220, permLayer: 227, wantLayer: 227},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore()
|
||||
bindings := NewTempAuthKeyBindingStore(keys)
|
||||
tempID := memoryAuthKeyID(int64(1_000 + i*2))
|
||||
permID := memoryAuthKeyID(int64(1_001 + i*2))
|
||||
tempBefore := store.AuthKeyData{
|
||||
ID: tempID, ExpiresAt: handshakeExpiry,
|
||||
Layer: tt.tempLayer, LayerObservationID: tt.tempObs, DeviceModel: "temp",
|
||||
}
|
||||
permBefore := store.AuthKeyData{
|
||||
ID: permID, Layer: tt.permLayer, LayerObservationID: tt.permObs, DeviceModel: "perm",
|
||||
}
|
||||
if err := keys.Save(ctx, tempBefore); err != nil {
|
||||
t.Fatalf("save temporary auth key: %v", err)
|
||||
}
|
||||
if err := keys.Save(ctx, permBefore); err != nil {
|
||||
t.Fatalf("save permanent auth key: %v", err)
|
||||
}
|
||||
binding := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempID,
|
||||
PermAuthKeyID: int64(binary.LittleEndian.Uint64(permID[:])),
|
||||
ExpiresAt: handshakeExpiry,
|
||||
}
|
||||
err := bindings.Save(ctx, binding)
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("bind error = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
tempAfter, _, _ := keys.Get(ctx, tempID)
|
||||
permAfter, _, _ := keys.Get(ctx, permID)
|
||||
if tempAfter != tempBefore || permAfter != permBefore {
|
||||
t.Fatalf("conflicting bind changed keys: temp=%+v perm=%+v", tempAfter, permAfter)
|
||||
}
|
||||
if _, found, getErr := bindings.GetByTemp(ctx, tempID); getErr != nil || found {
|
||||
t.Fatalf("conflicting binding found=%v err=%v, want absent", found, getErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("bind: %v", err)
|
||||
}
|
||||
tempAfter, tempFound, getErr := keys.Get(ctx, tempID)
|
||||
if getErr != nil || !tempFound {
|
||||
t.Fatalf("get temporary after bind: found=%v err=%v", tempFound, getErr)
|
||||
}
|
||||
permAfter, permFound, getErr := keys.Get(ctx, permID)
|
||||
if getErr != nil || !permFound {
|
||||
t.Fatalf("get permanent after bind: found=%v err=%v", permFound, getErr)
|
||||
}
|
||||
if tempAfter.Layer != tt.wantLayer || tempAfter.LayerObservationID != tt.wantObs ||
|
||||
permAfter.Layer != tt.wantLayer || permAfter.LayerObservationID != tt.wantObs {
|
||||
t.Fatalf("merged defaults: temp=(%d,%d) perm=(%d,%d), want=(%d,%d)",
|
||||
tempAfter.Layer, tempAfter.LayerObservationID,
|
||||
permAfter.Layer, permAfter.LayerObservationID,
|
||||
tt.wantLayer, tt.wantObs)
|
||||
}
|
||||
if tempAfter.DeviceModel != "temp" || permAfter.DeviceModel != "perm" {
|
||||
t.Fatalf("bind erased client metadata: temp=%+v perm=%+v", tempAfter, permAfter)
|
||||
}
|
||||
if _, found, getErr := bindings.GetByTemp(ctx, tempID); getErr != nil || !found {
|
||||
t.Fatalf("merged binding found=%v err=%v, want present", found, getErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTempAuthKeyBindingStoreIsIdempotentAndRejectsCrossPermanentRebind(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore()
|
||||
bindings := NewTempAuthKeyBindingStore(keys)
|
||||
handshakeExpiry := 400
|
||||
permID := memoryAuthKeyID(101)
|
||||
otherPermID := memoryAuthKeyID(102)
|
||||
first := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: [8]byte{8, 7, 6, 5, 4, 3, 2, 1},
|
||||
PermAuthKeyID: int64(binary.LittleEndian.Uint64(permID[:])),
|
||||
Nonce: 201,
|
||||
TempSessionID: 301,
|
||||
ExpiresAt: handshakeExpiry,
|
||||
EncryptedMessage: []byte("first"),
|
||||
}
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: permID}); err != nil {
|
||||
t.Fatalf("save permanent auth key: %v", err)
|
||||
}
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: otherPermID}); err != nil {
|
||||
t.Fatalf("save second permanent auth key: %v", err)
|
||||
}
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: first.TempAuthKeyID, ExpiresAt: handshakeExpiry}); err != nil {
|
||||
t.Fatalf("save temporary auth key: %v", err)
|
||||
}
|
||||
if err := bindings.Save(ctx, first); err != nil {
|
||||
t.Fatalf("save first: %v", err)
|
||||
}
|
||||
assertMemoryAuthKeyExpiry(t, ctx, keys, first.TempAuthKeyID, handshakeExpiry)
|
||||
|
||||
replayed := first
|
||||
replayed.Nonce = 202
|
||||
replayed.TempSessionID = 302
|
||||
replayed.ExpiresAt = 402
|
||||
replayed.EncryptedMessage = []byte("replayed")
|
||||
if err := bindings.Save(ctx, replayed); !errors.Is(err, store.ErrAuthKeyBindingInvalid) {
|
||||
t.Fatalf("replay with changed expiry error = %v, want %v", err, store.ErrAuthKeyBindingInvalid)
|
||||
}
|
||||
assertMemoryAuthKeyExpiry(t, ctx, keys, first.TempAuthKeyID, handshakeExpiry)
|
||||
got, found, err := bindings.GetByTemp(ctx, first.TempAuthKeyID)
|
||||
if err != nil || !found || got.ExpiresAt != first.ExpiresAt || got.Nonce != first.Nonce {
|
||||
t.Fatalf("binding after invalid expiry replay = %+v found=%v err=%v, want first binding", got, found, err)
|
||||
}
|
||||
|
||||
replayed.ExpiresAt = handshakeExpiry
|
||||
if err := bindings.Save(ctx, replayed); err != nil {
|
||||
t.Fatalf("replay same normalized binding: %v", err)
|
||||
}
|
||||
|
||||
forbidden := replayed
|
||||
forbidden.PermAuthKeyID = int64(binary.LittleEndian.Uint64(otherPermID[:]))
|
||||
forbidden.ExpiresAt = 999
|
||||
forbidden.EncryptedMessage = []byte("must not persist")
|
||||
if err := bindings.Save(ctx, forbidden); !errors.Is(err, store.ErrTempAuthKeyAlreadyBound) {
|
||||
t.Fatalf("cross-permanent rebind error = %v, want %v", err, store.ErrTempAuthKeyAlreadyBound)
|
||||
}
|
||||
assertMemoryAuthKeyExpiry(t, ctx, keys, first.TempAuthKeyID, handshakeExpiry)
|
||||
|
||||
got, found, err = bindings.GetByTemp(ctx, first.TempAuthKeyID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("get: found=%v err=%v", found, err)
|
||||
}
|
||||
if got.TempAuthKeyID != replayed.TempAuthKeyID || got.PermAuthKeyID != replayed.PermAuthKeyID ||
|
||||
got.Nonce != replayed.Nonce || got.TempSessionID != replayed.TempSessionID || got.ExpiresAt != replayed.ExpiresAt ||
|
||||
!bytes.Equal(got.EncryptedMessage, replayed.EncryptedMessage) {
|
||||
t.Fatalf("binding changed after forbidden rebind: got %+v, want %+v", got, replayed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTempAuthKeyBindingStoreRejectsMissingTypeAndExpiryViolations(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const handshakeExpiry = 500
|
||||
tempID := memoryAuthKeyID(201)
|
||||
permID := memoryAuthKeyID(202)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
temp *store.AuthKeyData
|
||||
perm *store.AuthKeyData
|
||||
bindingExpiry int
|
||||
}{
|
||||
{
|
||||
name: "missing temporary key",
|
||||
perm: &store.AuthKeyData{ID: permID},
|
||||
bindingExpiry: handshakeExpiry,
|
||||
},
|
||||
{
|
||||
name: "missing permanent key",
|
||||
temp: &store.AuthKeyData{ID: tempID, ExpiresAt: handshakeExpiry},
|
||||
bindingExpiry: handshakeExpiry,
|
||||
},
|
||||
{
|
||||
name: "temporary role uses permanent key",
|
||||
temp: &store.AuthKeyData{ID: tempID},
|
||||
perm: &store.AuthKeyData{ID: permID},
|
||||
bindingExpiry: handshakeExpiry,
|
||||
},
|
||||
{
|
||||
name: "permanent role uses temporary key",
|
||||
temp: &store.AuthKeyData{ID: tempID, ExpiresAt: handshakeExpiry},
|
||||
perm: &store.AuthKeyData{ID: permID, ExpiresAt: handshakeExpiry + 1},
|
||||
bindingExpiry: handshakeExpiry,
|
||||
},
|
||||
{
|
||||
name: "binding expiry differs from handshake",
|
||||
temp: &store.AuthKeyData{ID: tempID, ExpiresAt: handshakeExpiry},
|
||||
perm: &store.AuthKeyData{ID: permID},
|
||||
bindingExpiry: handshakeExpiry + 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
keys := NewAuthKeyStore()
|
||||
bindings := NewTempAuthKeyBindingStore(keys)
|
||||
if tt.temp != nil {
|
||||
if err := keys.Save(ctx, *tt.temp); err != nil {
|
||||
t.Fatalf("save temporary role key: %v", err)
|
||||
}
|
||||
}
|
||||
if tt.perm != nil {
|
||||
if err := keys.Save(ctx, *tt.perm); err != nil {
|
||||
t.Fatalf("save permanent role key: %v", err)
|
||||
}
|
||||
}
|
||||
err := bindings.Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempID,
|
||||
PermAuthKeyID: int64(binary.LittleEndian.Uint64(permID[:])),
|
||||
ExpiresAt: tt.bindingExpiry,
|
||||
})
|
||||
if !errors.Is(err, store.ErrAuthKeyBindingInvalid) {
|
||||
t.Fatalf("Save error = %v, want %v", err, store.ErrAuthKeyBindingInvalid)
|
||||
}
|
||||
if _, found, getErr := bindings.GetByTemp(ctx, tempID); getErr != nil || found {
|
||||
t.Fatalf("invalid binding found=%v err=%v, want absent", found, getErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthKeyStoreDeletePermanentRemovesBoundTemporaryIdentity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore()
|
||||
bindings := NewTempAuthKeyBindingStore(keys)
|
||||
tempID := memoryAuthKeyID(301)
|
||||
permID := memoryAuthKeyID(302)
|
||||
const expiresAt = 600
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: tempID, ExpiresAt: expiresAt}); err != nil {
|
||||
t.Fatalf("save temp: %v", err)
|
||||
}
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: permID}); err != nil {
|
||||
t.Fatalf("save perm: %v", err)
|
||||
}
|
||||
if err := bindings.Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempID,
|
||||
PermAuthKeyID: int64(binary.LittleEndian.Uint64(permID[:])),
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
t.Fatalf("save binding: %v", err)
|
||||
}
|
||||
|
||||
if err := keys.Delete(ctx, permID); err != nil {
|
||||
t.Fatalf("delete permanent key: %v", err)
|
||||
}
|
||||
if _, found, err := keys.Get(ctx, permID); err != nil || found {
|
||||
t.Fatalf("permanent key found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
if _, found, err := keys.Get(ctx, tempID); err != nil || found {
|
||||
t.Fatalf("bound temporary key found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
if _, found, err := bindings.GetByTemp(ctx, tempID); err != nil || found {
|
||||
t.Fatalf("binding found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTempAuthKeyBindingStoreDeleteExpiredUsesAuthKeyExpiry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore()
|
||||
bindings := NewTempAuthKeyBindingStore(keys)
|
||||
permID := memoryAuthKeyID(401)
|
||||
boundExpiredID := memoryAuthKeyID(402)
|
||||
unboundExpiredID := memoryAuthKeyID(403)
|
||||
liveID := memoryAuthKeyID(404)
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: permID}); err != nil {
|
||||
t.Fatalf("save perm: %v", err)
|
||||
}
|
||||
for id, expiry := range map[[8]byte]int{
|
||||
boundExpiredID: 700,
|
||||
unboundExpiredID: 701,
|
||||
liveID: 900,
|
||||
} {
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id, ExpiresAt: expiry}); err != nil {
|
||||
t.Fatalf("save temp %x: %v", id, err)
|
||||
}
|
||||
}
|
||||
if err := bindings.Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: boundExpiredID,
|
||||
PermAuthKeyID: int64(binary.LittleEndian.Uint64(permID[:])),
|
||||
ExpiresAt: 700,
|
||||
}); err != nil {
|
||||
t.Fatalf("save binding: %v", err)
|
||||
}
|
||||
|
||||
deleted, err := bindings.DeleteExpired(ctx, 800, 10)
|
||||
if err != nil || deleted != 2 {
|
||||
t.Fatalf("DeleteExpired = %d, %v; want 2, nil", deleted, err)
|
||||
}
|
||||
for _, id := range [][8]byte{boundExpiredID, unboundExpiredID} {
|
||||
if _, found, getErr := keys.Get(ctx, id); getErr != nil || found {
|
||||
t.Fatalf("expired key %x found=%v err=%v, want absent", id, found, getErr)
|
||||
}
|
||||
}
|
||||
if _, found, err := bindings.GetByTemp(ctx, boundExpiredID); err != nil || found {
|
||||
t.Fatalf("expired binding found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
for _, id := range [][8]byte{permID, liveID} {
|
||||
if _, found, getErr := keys.Get(ctx, id); getErr != nil || !found {
|
||||
t.Fatalf("retained key %x found=%v err=%v, want present", id, found, getErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func memoryAuthKeyID(id int64) [8]byte {
|
||||
var out [8]byte
|
||||
binary.LittleEndian.PutUint64(out[:], uint64(id))
|
||||
return out
|
||||
}
|
||||
|
||||
func assertMemoryAuthKeyExpiry(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
keys store.AuthKeyStore,
|
||||
id [8]byte,
|
||||
want int,
|
||||
) {
|
||||
t.Helper()
|
||||
got, found, err := keys.Get(ctx, id)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("get auth key: found=%v err=%v", found, err)
|
||||
}
|
||||
if got.ExpiresAt != want {
|
||||
t.Fatalf("auth key expires_at = %d, want handshake expiry %d", got.ExpiresAt, want)
|
||||
}
|
||||
}
|
||||
163
internal/store/memory/authkey_session_layer.go
Normal file
163
internal/store/memory/authkey_session_layer.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
type authKeySessionLayerKey struct {
|
||||
rawAuthKeyID [8]byte
|
||||
sessionID int64
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) GetSessionLayer(
|
||||
_ context.Context,
|
||||
rawAuthKeyID [8]byte,
|
||||
sessionID int64,
|
||||
) (store.AuthKeySessionLayer, bool, error) {
|
||||
now := time.Now()
|
||||
key := authKeySessionLayerKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}
|
||||
s.state.mu.Lock()
|
||||
value, found := s.state.sessionLayers[key]
|
||||
if found && !now.Before(value.ExpiresAt) {
|
||||
delete(s.state.sessionLayers, key)
|
||||
found = false
|
||||
value = store.AuthKeySessionLayer{}
|
||||
} else if found {
|
||||
value.SharedDefault = s.state.sessionLayerIsSharedDefaultLocked(rawAuthKeyID, value)
|
||||
}
|
||||
s.state.mu.Unlock()
|
||||
return value, found, nil
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) AdvanceSessionLayer(
|
||||
_ context.Context,
|
||||
rawAuthKeyID [8]byte,
|
||||
sessionID int64,
|
||||
layer int,
|
||||
msgID int64,
|
||||
) (store.AuthKeySessionLayer, bool, error) {
|
||||
now := time.Now()
|
||||
expiresAt, freshEvidence := store.AuthKeySessionLayerEvidenceFresh(now, msgID)
|
||||
if layer <= 0 || !freshEvidence {
|
||||
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeySessionLayerInvalid
|
||||
}
|
||||
key := authKeySessionLayerKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}
|
||||
s.state.mu.Lock()
|
||||
defer s.state.mu.Unlock()
|
||||
if _, found := s.state.keys[rawAuthKeyID]; !found {
|
||||
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeyNotFound
|
||||
}
|
||||
current, found := s.state.sessionLayers[key]
|
||||
if found && now.Before(current.ExpiresAt) {
|
||||
switch {
|
||||
case msgID < current.MessageID:
|
||||
current.SharedDefault = s.state.sessionLayerIsSharedDefaultLocked(rawAuthKeyID, current)
|
||||
return current, false, nil
|
||||
case msgID == current.MessageID:
|
||||
if layer != current.Layer {
|
||||
return current, false, store.ErrAuthKeySessionLayerConflict
|
||||
}
|
||||
current.SharedDefault = s.state.sessionLayerIsSharedDefaultLocked(rawAuthKeyID, current)
|
||||
return current, false, nil
|
||||
}
|
||||
}
|
||||
var (
|
||||
permID [8]byte
|
||||
perm store.AuthKeyData
|
||||
bound bool
|
||||
)
|
||||
if binding, ok := s.state.bindings[rawAuthKeyID]; ok {
|
||||
bound = true
|
||||
binary.LittleEndian.PutUint64(permID[:], uint64(binding.PermAuthKeyID))
|
||||
var permFound bool
|
||||
perm, permFound = s.state.keys[permID]
|
||||
if !permFound {
|
||||
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeyBindingInvalid
|
||||
}
|
||||
}
|
||||
if s.state.nextLayerObservation == math.MaxInt64 {
|
||||
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeySessionLayerInvalid
|
||||
}
|
||||
s.state.nextLayerObservation++
|
||||
current = store.AuthKeySessionLayer{
|
||||
Layer: layer,
|
||||
MessageID: msgID,
|
||||
ObservationID: s.state.nextLayerObservation,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
stored := current
|
||||
stored.SharedDefault = false
|
||||
s.state.sessionLayers[key] = stored
|
||||
raw := s.state.keys[rawAuthKeyID]
|
||||
raw.Layer = layer
|
||||
raw.LayerObservationID = current.ObservationID
|
||||
s.state.keys[rawAuthKeyID] = raw
|
||||
if bound {
|
||||
perm.Layer = layer
|
||||
perm.LayerObservationID = current.ObservationID
|
||||
s.state.keys[permID] = perm
|
||||
s.state.mirrorAuthorizationLayersLocked([][8]byte{rawAuthKeyID, permID}, layer)
|
||||
} else {
|
||||
s.state.mirrorAuthorizationLayersLocked([][8]byte{rawAuthKeyID}, layer)
|
||||
}
|
||||
current.SharedDefault = true
|
||||
return current, true, nil
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) DeleteSessionLayer(
|
||||
_ context.Context,
|
||||
rawAuthKeyID [8]byte,
|
||||
sessionID int64,
|
||||
) (bool, error) {
|
||||
key := authKeySessionLayerKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}
|
||||
s.state.mu.Lock()
|
||||
_, deleted := s.state.sessionLayers[key]
|
||||
delete(s.state.sessionLayers, key)
|
||||
s.state.mu.Unlock()
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) DeleteExpiredSessionLayers(_ context.Context, limit int) (int, error) {
|
||||
if limit <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
now := time.Now()
|
||||
s.state.mu.Lock()
|
||||
deleted := 0
|
||||
for key, value := range s.state.sessionLayers {
|
||||
if deleted >= limit {
|
||||
break
|
||||
}
|
||||
if now.Before(value.ExpiresAt) {
|
||||
continue
|
||||
}
|
||||
delete(s.state.sessionLayers, key)
|
||||
deleted++
|
||||
}
|
||||
s.state.mu.Unlock()
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (s *authKeyState) deleteSessionLayersLocked(rawAuthKeyID [8]byte) {
|
||||
for key := range s.sessionLayers {
|
||||
if key.rawAuthKeyID == rawAuthKeyID {
|
||||
delete(s.sessionLayers, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *authKeyState) sessionLayerIsSharedDefaultLocked(rawAuthKeyID [8]byte, value store.AuthKeySessionLayer) bool {
|
||||
defaultKeyID := rawAuthKeyID
|
||||
if binding, ok := s.bindings[rawAuthKeyID]; ok {
|
||||
binary.LittleEndian.PutUint64(defaultKeyID[:], uint64(binding.PermAuthKeyID))
|
||||
}
|
||||
key, found := s.keys[defaultKeyID]
|
||||
return found &&
|
||||
key.Layer == value.Layer &&
|
||||
key.LayerObservationID == value.ObservationID
|
||||
}
|
||||
235
internal/store/memory/authkey_session_layer_test.go
Normal file
235
internal/store/memory/authkey_session_layer_test.go
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestAuthKeySessionLayerOrdersRestartEvidenceAndBindingDefaults(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore()
|
||||
bindings := NewTempAuthKeyBindingStore(keys)
|
||||
temp := [8]byte{1}
|
||||
perm := [8]byte{2}
|
||||
const tempExpiry = 2_000_000_000
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: temp, ExpiresAt: tempExpiry}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: perm, ExpiresAt: 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
firstMsgID := authKeySessionLayerTestMsgID(now, 1)
|
||||
newerMsgID := authKeySessionLayerTestMsgID(now, 2)
|
||||
otherMsgID := authKeySessionLayerTestMsgID(now, 3)
|
||||
|
||||
first, applied, err := keys.AdvanceSessionLayer(ctx, temp, 10, 220, firstMsgID)
|
||||
if err != nil || !applied || !first.SharedDefault || first.ObservationID <= 0 {
|
||||
t.Fatalf("first advance = (%+v,%v,%v)", first, applied, err)
|
||||
}
|
||||
var permInt [8]byte
|
||||
copy(permInt[:], perm[:])
|
||||
if err := bindings.Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp,
|
||||
PermAuthKeyID: int64(binary.LittleEndian.Uint64(permInt[:])),
|
||||
ExpiresAt: tempExpiry,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range [][8]byte{temp, perm} {
|
||||
got, found, err := keys.Get(ctx, id)
|
||||
if err != nil || !found || got.Layer != 220 || got.LayerObservationID != first.ObservationID {
|
||||
t.Fatalf("bound default %x = (%+v,%v,%v)", id, got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
newer, applied, err := keys.AdvanceSessionLayer(ctx, temp, 10, 227, newerMsgID)
|
||||
if err != nil || !applied || !newer.SharedDefault || newer.ObservationID <= first.ObservationID {
|
||||
t.Fatalf("newer advance = (%+v,%v,%v)", newer, applied, err)
|
||||
}
|
||||
older, applied, err := keys.AdvanceSessionLayer(ctx, temp, 10, 220, firstMsgID)
|
||||
if err != nil || applied || older.Layer != 227 || older.MessageID != newerMsgID || !older.SharedDefault {
|
||||
t.Fatalf("older replay = (%+v,%v,%v)", older, applied, err)
|
||||
}
|
||||
if _, _, err := keys.AdvanceSessionLayer(ctx, temp, 10, 220, newerMsgID); !errors.Is(err, store.ErrAuthKeySessionLayerConflict) {
|
||||
t.Fatalf("same-msg conflict = %v", err)
|
||||
}
|
||||
|
||||
other, applied, err := keys.AdvanceSessionLayer(ctx, temp, 11, 225, otherMsgID)
|
||||
if err != nil || !applied || !other.SharedDefault || other.ObservationID <= newer.ObservationID {
|
||||
t.Fatalf("other-session advance = (%+v,%v,%v)", other, applied, err)
|
||||
}
|
||||
oldSession, found, err := keys.GetSessionLayer(ctx, temp, 10)
|
||||
if err != nil || !found || oldSession.Layer != 227 || oldSession.SharedDefault {
|
||||
t.Fatalf("old exact session after other default = (%+v,%v,%v)", oldSession, found, err)
|
||||
}
|
||||
for _, id := range [][8]byte{temp, perm} {
|
||||
got, _, _ := keys.Get(ctx, id)
|
||||
if got.Layer != 225 || got.LayerObservationID != other.ObservationID {
|
||||
t.Fatalf("shared default %x = layer %d observation %d", id, got.Layer, got.LayerObservationID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthKeySessionLayerExpiryDeleteAndAuthKeyCascade(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore()
|
||||
id := [8]byte{3}
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
seedMsgID := authKeySessionLayerTestMsgID(now, 1)
|
||||
replacementMsgID := authKeySessionLayerTestMsgID(now.Add(time.Second), 1)
|
||||
otherMsgID := authKeySessionLayerTestMsgID(now.Add(time.Second), 2)
|
||||
if _, _, err := keys.AdvanceSessionLayer(
|
||||
ctx, id, 20, 227, authKeySessionLayerTestMsgID(now.Add(-302*time.Second), 1),
|
||||
); !errors.Is(err, store.ErrAuthKeySessionLayerInvalid) {
|
||||
t.Fatalf("stale evidence err = %v", err)
|
||||
}
|
||||
if _, applied, err := keys.AdvanceSessionLayer(ctx, id, 20, 227, seedMsgID); err != nil || !applied {
|
||||
t.Fatalf("seed = applied %v err %v", applied, err)
|
||||
}
|
||||
// Model the same once-valid row after wall-clock retention elapsed. Tests do
|
||||
// not pass an arbitrary expiry through the production write API.
|
||||
key := authKeySessionLayerKey{rawAuthKeyID: id, sessionID: 20}
|
||||
keys.state.mu.Lock()
|
||||
expired := keys.state.sessionLayers[key]
|
||||
expired.ExpiresAt = time.Now().Add(-time.Second)
|
||||
keys.state.sessionLayers[key] = expired
|
||||
keys.state.mu.Unlock()
|
||||
if _, found, err := keys.GetSessionLayer(ctx, id, 20); err != nil || found {
|
||||
t.Fatalf("expired lookup = found %v err %v", found, err)
|
||||
}
|
||||
current, applied, err := keys.AdvanceSessionLayer(ctx, id, 20, 220, replacementMsgID)
|
||||
if err != nil || !applied || current.Layer != 220 {
|
||||
t.Fatalf("expired replacement = (%+v,%v,%v)", current, applied, err)
|
||||
}
|
||||
if deleted, err := keys.DeleteSessionLayer(ctx, id, 20); err != nil || !deleted {
|
||||
t.Fatalf("delete session layer = (%v,%v)", deleted, err)
|
||||
}
|
||||
if _, applied, err := keys.AdvanceSessionLayer(ctx, id, 21, 227, otherMsgID); err != nil || !applied {
|
||||
t.Fatalf("cascade seed = applied %v err %v", applied, err)
|
||||
}
|
||||
if err := keys.Delete(ctx, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found, err := keys.GetSessionLayer(ctx, id, 21); err != nil || found {
|
||||
t.Fatalf("auth key cascade = found %v err %v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthKeySessionLayerObservationContinuesAfterRestoredAuthKeyWatermark(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore()
|
||||
id := [8]byte{4}
|
||||
if err := keys.Save(ctx, store.AuthKeyData{
|
||||
ID: id, Layer: 225, LayerObservationID: 91,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
current, applied, err := keys.AdvanceSessionLayer(
|
||||
ctx, id, 30, 227, authKeySessionLayerTestMsgID(time.Now().UTC(), 1),
|
||||
)
|
||||
if err != nil || !applied {
|
||||
t.Fatalf("advance restored watermark = (%+v,%v,%v)", current, applied, err)
|
||||
}
|
||||
if current.ObservationID <= 91 {
|
||||
t.Fatalf("new observation = %d, want > 91", current.ObservationID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthKeyLayerAuthorityKeepsAuthorizationProjectionInParity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
t.Run("advance before stale bind", func(t *testing.T) {
|
||||
keys := NewAuthKeyStore()
|
||||
auths := NewAuthorizationStore()
|
||||
auths.LinkAuthKeyAuthority(keys)
|
||||
perm := memoryAuthKeyID(8_701)
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: perm}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
advanced, applied, err := keys.AdvanceSessionLayer(
|
||||
ctx, perm, 1, 227, authKeySessionLayerTestMsgID(time.Now().UTC(), 1),
|
||||
)
|
||||
if err != nil || !applied {
|
||||
t.Fatalf("advance = (%+v,%v,%v)", advanced, applied, err)
|
||||
}
|
||||
if err := auths.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: perm, UserID: 1, Layer: 220,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, found, err := auths.ByAuthKey(ctx, perm)
|
||||
if err != nil || !found || got.Layer != 227 {
|
||||
t.Fatalf("authorization after stale bind = (%+v,%v,%v)", got, found, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bind before advance", func(t *testing.T) {
|
||||
keys := NewAuthKeyStore()
|
||||
auths := NewAuthorizationStore()
|
||||
auths.LinkAuthKeyAuthority(keys)
|
||||
perm := memoryAuthKeyID(8_702)
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: perm, Layer: 220}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: perm, UserID: 2, Layer: 225}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := keys.AdvanceSessionLayer(
|
||||
ctx, perm, 2, 227, authKeySessionLayerTestMsgID(time.Now().UTC(), 2),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, found, err := auths.ByAuthKey(ctx, perm)
|
||||
if err != nil || !found || got.Layer != 227 {
|
||||
t.Fatalf("authorization after advance = (%+v,%v,%v)", got, found, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("temp merge", func(t *testing.T) {
|
||||
keys := NewAuthKeyStore()
|
||||
auths := NewAuthorizationStore()
|
||||
auths.LinkAuthKeyAuthority(keys)
|
||||
bindings := NewTempAuthKeyBindingStore(keys)
|
||||
temp := memoryAuthKeyID(8_703)
|
||||
perm := memoryAuthKeyID(8_704)
|
||||
const expiresAt = 2_000_000_000
|
||||
if err := keys.Save(ctx, store.AuthKeyData{
|
||||
ID: temp, ExpiresAt: expiresAt, Layer: 225, LayerObservationID: 20,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := keys.Save(ctx, store.AuthKeyData{
|
||||
ID: perm, Layer: 220, LayerObservationID: 10,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: perm, UserID: 3, Layer: 227}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bindings.Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp,
|
||||
PermAuthKeyID: int64(binary.LittleEndian.Uint64(perm[:])),
|
||||
ExpiresAt: expiresAt,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, found, err := auths.ByAuthKey(ctx, perm)
|
||||
if err != nil || !found || got.Layer != 225 {
|
||||
t.Fatalf("authorization after temp merge = (%+v,%v,%v)", got, found, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func authKeySessionLayerTestMsgID(at time.Time, order uint32) int64 {
|
||||
return int64((uint64(at.Unix()) << 32) | uint64(order)<<2)
|
||||
}
|
||||
|
|
@ -2,19 +2,41 @@ package memory
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// LangPackStore 是 store.LangPackStore 的内存实现。
|
||||
type LangPackStore struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]domain.LangPack
|
||||
mu sync.RWMutex
|
||||
m map[string]domain.LangPack
|
||||
seedHashes map[string]string
|
||||
catalogs map[string]domain.LangPackSeedCatalog
|
||||
}
|
||||
|
||||
// NewLangPackStore 创建内存 LangPackStore。
|
||||
func NewLangPackStore() *LangPackStore {
|
||||
return &LangPackStore{m: make(map[string]domain.LangPack)}
|
||||
return &LangPackStore{
|
||||
m: make(map[string]domain.LangPack),
|
||||
seedHashes: make(map[string]string),
|
||||
catalogs: make(map[string]domain.LangPackSeedCatalog),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LangPackStore) GetSeedCatalog(_ context.Context, catalog string) (domain.LangPackSeedCatalog, error) {
|
||||
if catalog == "" {
|
||||
catalog = "default"
|
||||
}
|
||||
s.mu.RLock()
|
||||
state := s.catalogs[catalog]
|
||||
s.mu.RUnlock()
|
||||
state.Scopes = append([]string(nil), state.Scopes...)
|
||||
state.Packs = append([]domain.LangPackSeedCatalogEntry(nil), state.Packs...)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *LangPackStore) GetPack(_ context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) {
|
||||
|
|
@ -57,14 +79,171 @@ func (s *LangPackStore) GetStrings(_ context.Context, langPack, langCode string,
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *LangPackStore) ListLanguages(_ context.Context, langPack string) ([]domain.LangPackLanguage, error) {
|
||||
s.mu.RLock()
|
||||
packs := make([]domain.LangPack, 0)
|
||||
for _, pack := range s.m {
|
||||
if pack.LangPack == langPack {
|
||||
packs = append(packs, pack)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
sort.Slice(packs, func(i, j int) bool {
|
||||
return packs[i].LangCode < packs[j].LangCode
|
||||
})
|
||||
out := make([]domain.LangPackLanguage, 0, len(packs))
|
||||
for _, pack := range packs {
|
||||
codeKey := langPackCodeKey(pack.LangCode)
|
||||
lang := domain.LangPackLanguage{
|
||||
LangPack: pack.LangPack,
|
||||
LangCode: pack.LangCode,
|
||||
StringsCount: len(pack.Strings),
|
||||
TranslatedCount: len(pack.Strings),
|
||||
}
|
||||
for _, item := range pack.Strings {
|
||||
switch item.Key {
|
||||
case "LanguageNameInEnglish", "Localization.EnglishLanguageName":
|
||||
if lang.Name == "" {
|
||||
lang.Name = item.Value
|
||||
}
|
||||
case "lng_language_name", "Localization.LanguageName":
|
||||
if lang.NativeName == "" {
|
||||
lang.NativeName = item.Value
|
||||
}
|
||||
case "LanguageName":
|
||||
if lang.NativeName == "" && pack.LangPack != "android" {
|
||||
lang.NativeName = item.Value
|
||||
}
|
||||
case "TranslateLanguage" + codeKey, "PassportLanguage_" + codeKey:
|
||||
if lang.NativeName == "" || pack.LangPack == "android" {
|
||||
lang.NativeName = item.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, lang)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *LangPackStore) ReconcileSeed(_ context.Context, seed domain.LangPackSeed) (int, error) {
|
||||
catalog := seed.Catalog
|
||||
if catalog == "" {
|
||||
catalog = "default"
|
||||
}
|
||||
scopes := make(map[string]struct{}, len(seed.Scopes))
|
||||
for _, scope := range seed.Scopes {
|
||||
scopes[scope] = struct{}{}
|
||||
}
|
||||
wanted := make(map[string]domain.LangPackSeedEntry, len(seed.Packs))
|
||||
for _, entry := range seed.Packs {
|
||||
if _, scoped := scopes[entry.Pack.LangPack]; !scoped {
|
||||
return 0, fmt.Errorf("seeded langpack %s/%s is outside reconciliation scopes", entry.Pack.LangPack, entry.Pack.LangCode)
|
||||
}
|
||||
key := langPackKey(entry.Pack.LangPack, entry.Pack.LangCode)
|
||||
if _, exists := wanted[key]; exists {
|
||||
return 0, fmt.Errorf("duplicate seeded langpack %s/%s", entry.Pack.LangPack, entry.Pack.LangCode)
|
||||
}
|
||||
wanted[key] = entry
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, scope := range s.catalogs[catalog].Scopes {
|
||||
scopes[scope] = struct{}{}
|
||||
}
|
||||
for key, entry := range wanted {
|
||||
if err := validateSeedEntry(entry); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
current, exists := s.m[key]
|
||||
if exists {
|
||||
if entry.Pack.Version < current.Version {
|
||||
return 0, fmt.Errorf("langpack version rollback %s/%s: %d < %d", entry.Pack.LangPack, entry.Pack.LangCode, entry.Pack.Version, current.Version)
|
||||
}
|
||||
if oldHash := s.seedHashes[key]; oldHash != "" && entry.Pack.Version == current.Version && oldHash != entry.ContentHash {
|
||||
return 0, fmt.Errorf("langpack %s/%s v%d content changed without version bump", entry.Pack.LangPack, entry.Pack.LangCode, entry.Pack.Version)
|
||||
}
|
||||
}
|
||||
unchanged := exists && current.Version == entry.Pack.Version && len(current.Strings) == entry.StringsCount && s.seedHashes[key] == entry.ContentHash
|
||||
if !unchanged && !entry.ContentLoaded {
|
||||
return 0, fmt.Errorf("langpack %s/%s content is required but was not loaded", entry.Pack.LangPack, entry.Pack.LangCode)
|
||||
}
|
||||
}
|
||||
|
||||
for key, pack := range s.m {
|
||||
if _, scoped := scopes[pack.LangPack]; !scoped {
|
||||
continue
|
||||
}
|
||||
if _, exists := wanted[key]; !exists {
|
||||
delete(s.m, key)
|
||||
delete(s.seedHashes, key)
|
||||
}
|
||||
}
|
||||
written := 0
|
||||
for key, entry := range wanted {
|
||||
current, exists := s.m[key]
|
||||
if exists && current.Version == entry.Pack.Version && len(current.Strings) == entry.StringsCount && s.seedHashes[key] == entry.ContentHash {
|
||||
continue
|
||||
}
|
||||
pack := entry.Pack
|
||||
pack.Strings = append([]domain.LangPackString(nil), pack.Strings...)
|
||||
s.m[key] = pack
|
||||
s.seedHashes[key] = entry.ContentHash
|
||||
written += len(pack.Strings)
|
||||
}
|
||||
s.catalogs[catalog] = seedCatalogSnapshot(seed)
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func (s *LangPackStore) UpsertPack(_ context.Context, pack domain.LangPack) error {
|
||||
pack.Strings = append([]domain.LangPackString(nil), pack.Strings...)
|
||||
s.mu.Lock()
|
||||
s.m[langPackKey(pack.LangPack, pack.LangCode)] = pack
|
||||
key := langPackKey(pack.LangPack, pack.LangCode)
|
||||
s.m[key] = pack
|
||||
delete(s.seedHashes, key)
|
||||
clear(s.catalogs)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSeedEntry(entry domain.LangPackSeedEntry) error {
|
||||
if entry.SourceHash == "" || entry.ContentHash == "" || entry.StringsCount <= 0 {
|
||||
return fmt.Errorf("langpack %s/%s has incomplete seed metadata", entry.Pack.LangPack, entry.Pack.LangCode)
|
||||
}
|
||||
if entry.ContentLoaded && len(entry.Pack.Strings) != entry.StringsCount {
|
||||
return fmt.Errorf("langpack %s/%s loaded %d strings, want %d", entry.Pack.LangPack, entry.Pack.LangCode, len(entry.Pack.Strings), entry.StringsCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedCatalogSnapshot(seed domain.LangPackSeed) domain.LangPackSeedCatalog {
|
||||
state := domain.LangPackSeedCatalog{
|
||||
Catalog: seed.Catalog,
|
||||
Scopes: append([]string(nil), seed.Scopes...),
|
||||
Packs: make([]domain.LangPackSeedCatalogEntry, 0, len(seed.Packs)),
|
||||
}
|
||||
for _, entry := range seed.Packs {
|
||||
state.Packs = append(state.Packs, domain.LangPackSeedCatalogEntry{
|
||||
LangPack: entry.Pack.LangPack,
|
||||
LangCode: entry.Pack.LangCode,
|
||||
Version: entry.Pack.Version,
|
||||
SourceHash: entry.SourceHash,
|
||||
ContentHash: entry.ContentHash,
|
||||
StringsCount: entry.StringsCount,
|
||||
})
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func langPackKey(langPack, langCode string) string {
|
||||
return langPack + "\x00" + langCode
|
||||
}
|
||||
|
||||
func langPackCodeKey(langCode string) string {
|
||||
base := strings.ToUpper(strings.TrimSpace(langCode))
|
||||
if idx := strings.IndexAny(base, "-_"); idx >= 0 {
|
||||
base = base[:idx]
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,11 +191,11 @@ func (s *CodeStore) InvalidateLoginCode(_ context.Context, hash, phone string) (
|
|||
}
|
||||
|
||||
func loginCodeVerifiable(record store.PhoneCode) bool {
|
||||
return record.Channel == store.PhoneCodeChannelPhone || record.Channel == store.PhoneCodeChannelEmailLogin
|
||||
return store.LoginCodeChannelVerifiable(record.Channel)
|
||||
}
|
||||
|
||||
func loginCodeTakeable(record store.PhoneCode) bool {
|
||||
return loginCodeVerifiable(record) || record.Channel == store.PhoneCodeChannelEmailSetupRequired
|
||||
return store.LoginCodeChannelTakeable(record.Channel)
|
||||
}
|
||||
|
||||
func (s *CodeStore) liveCodeLocked(hash string) (codeEntry, bool) {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ func (s *MessageStore) Create(_ context.Context, msg domain.Message) (domain.Mes
|
|||
}
|
||||
|
||||
func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) {
|
||||
if !req.HasContent() {
|
||||
return domain.SendPrivateTextResult{}, domain.ErrMessageEmpty
|
||||
}
|
||||
fingerprint, err := store.PrivateSendFingerprint(req)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
|
|
|
|||
|
|
@ -90,6 +90,35 @@ func TestMessageStoreSendPrivateTextCreatesBothOwnerBoxes(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMessageStoreSendPrivateTextContentInvariant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
|
||||
richOnly, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: 1000000001,
|
||||
RecipientUserID: 1000000002,
|
||||
RandomID: 191,
|
||||
Date: 1700000191,
|
||||
RichMessage: &domain.MessageRichMessage{Blocks: validRichMessageBlocks},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText rich-only: %v", err)
|
||||
}
|
||||
if richOnly.SenderMessage.Body != "" || richOnly.SenderMessage.RichMessage.IsZero() {
|
||||
t.Fatalf("rich-only sender message = %+v, want empty body with rich payload", richOnly.SenderMessage)
|
||||
}
|
||||
|
||||
_, err = messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: 1000000001,
|
||||
RecipientUserID: 1000000002,
|
||||
RandomID: 192,
|
||||
Date: 1700000192,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrMessageEmpty) {
|
||||
t.Fatalf("SendPrivateText empty err = %v, want ErrMessageEmpty", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStoreEditRichOnlyMessageUsesFinalContentState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package memory
|
|||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -10,14 +11,256 @@ import (
|
|||
|
||||
// StarGiftStore 是 store.StarGiftStore 的内存实现。
|
||||
type StarGiftStore struct {
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
gifts []domain.SavedStarGift // 追加序
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
nextGiftID int64
|
||||
nextRevID int64
|
||||
gifts []domain.SavedStarGift // 追加序
|
||||
catalog map[int64]domain.StarGift
|
||||
revisions map[int64]domain.StarGift
|
||||
enabled map[int64]bool
|
||||
sortOrder map[int64]int
|
||||
animations map[int64][]byte
|
||||
collectibles map[int64]domain.StarGiftCollectibleRevision
|
||||
uniqueByID map[int64]domain.UniqueStarGift
|
||||
uniqueBySlug map[string]int64
|
||||
collections map[domain.Peer][]domain.StarGiftCollection
|
||||
nextAttributeID int64
|
||||
nextCollectionID int
|
||||
}
|
||||
|
||||
// NewStarGiftStore 创建内存 StarGiftStore。
|
||||
func NewStarGiftStore() *StarGiftStore {
|
||||
return &StarGiftStore{}
|
||||
return &StarGiftStore{
|
||||
catalog: make(map[int64]domain.StarGift), revisions: make(map[int64]domain.StarGift),
|
||||
enabled: make(map[int64]bool), sortOrder: make(map[int64]int), animations: make(map[int64][]byte),
|
||||
collectibles: make(map[int64]domain.StarGiftCollectibleRevision),
|
||||
uniqueByID: make(map[int64]domain.UniqueStarGift), uniqueBySlug: make(map[string]int64),
|
||||
collections: make(map[domain.Peer][]domain.StarGiftCollection),
|
||||
}
|
||||
}
|
||||
|
||||
// SeedCatalog installs valid immutable catalog snapshots for tests.
|
||||
func (s *StarGiftStore) SeedCatalog(gifts []domain.StarGift) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, gift := range gifts {
|
||||
if gift.RevisionID == 0 {
|
||||
s.nextRevID++
|
||||
gift.RevisionID = s.nextRevID
|
||||
}
|
||||
if gift.ID > s.nextGiftID {
|
||||
s.nextGiftID = gift.ID
|
||||
}
|
||||
if gift.RevisionID > s.nextRevID {
|
||||
s.nextRevID = gift.RevisionID
|
||||
}
|
||||
s.catalog[gift.ID] = gift
|
||||
s.revisions[gift.RevisionID] = gift
|
||||
s.enabled[gift.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) Catalog(_ context.Context) ([]domain.StarGift, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domain.StarGift, 0, len(s.catalog))
|
||||
for id, gift := range s.catalog {
|
||||
if s.enabled[id] {
|
||||
out = append(out, gift)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if s.sortOrder[out[i].ID] == s.sortOrder[out[j].ID] {
|
||||
return out[i].ID < out[j].ID
|
||||
}
|
||||
return s.sortOrder[out[i].ID] < s.sortOrder[out[j].ID]
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CatalogGift(_ context.Context, giftID int64) (domain.StarGift, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
gift, ok := s.catalog[giftID]
|
||||
return gift, ok && s.enabled[giftID], nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CatalogRevision(_ context.Context, revisionID int64) (domain.StarGift, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
gift, ok := s.revisions[revisionID]
|
||||
return gift, ok, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CreateCatalogRevision(_ context.Context, write domain.StarGiftCatalogWrite) (domain.StarGiftCatalogEntry, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
giftID := write.GiftID
|
||||
if giftID == 0 {
|
||||
s.nextGiftID++
|
||||
giftID = s.nextGiftID
|
||||
} else if _, ok := s.catalog[giftID]; !ok {
|
||||
return domain.StarGiftCatalogEntry{}, domain.ErrStarGiftNotFound
|
||||
}
|
||||
s.nextRevID++
|
||||
gift := domain.StarGift{ID: giftID, RevisionID: s.nextRevID, Stars: write.Stars, ConvertStars: write.ConvertStars, Title: write.Title, Sticker: write.Document}
|
||||
s.catalog[giftID] = gift
|
||||
s.revisions[gift.RevisionID] = gift
|
||||
s.enabled[giftID] = write.Enabled
|
||||
s.sortOrder[giftID] = write.SortOrder
|
||||
s.animations[giftID] = append([]byte(nil), write.Animation.JSON...)
|
||||
return domain.StarGiftCatalogEntry{Gift: gift, Enabled: write.Enabled, SortOrder: write.SortOrder}, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) SetCatalogEnabled(_ context.Context, giftID int64, enabled bool) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.catalog[giftID]; !ok {
|
||||
return false, domain.ErrStarGiftNotFound
|
||||
}
|
||||
changed := s.enabled[giftID] != enabled
|
||||
s.enabled[giftID] = enabled
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) SetCatalogSortOrder(_ context.Context, giftID int64, sortOrder int) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.catalog[giftID]; !ok {
|
||||
return false, domain.ErrStarGiftNotFound
|
||||
}
|
||||
changed := s.sortOrder[giftID] != sortOrder
|
||||
s.sortOrder[giftID] = sortOrder
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) AnimationJSON(_ context.Context, giftID int64) ([]byte, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
raw, ok := s.animations[giftID]
|
||||
return append([]byte(nil), raw...), ok, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) PublishCollectibleRevision(_ context.Context, write domain.StarGiftCollectibleWrite) (domain.StarGiftCollectibleRevision, error) {
|
||||
if err := domain.ValidateStarGiftCollectibleWrite(write); err != nil {
|
||||
return domain.StarGiftCollectibleRevision{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.catalog[write.GiftID]; !ok {
|
||||
return domain.StarGiftCollectibleRevision{}, domain.ErrStarGiftNotFound
|
||||
}
|
||||
previous := s.collectibles[write.GiftID]
|
||||
revision := domain.StarGiftCollectibleRevision{
|
||||
ID: previous.ID + 1, GiftID: write.GiftID, Revision: previous.Revision + 1,
|
||||
UpgradeStars: write.UpgradeStars, SupplyTotal: write.SupplyTotal,
|
||||
SlugPrefix: strings.ToLower(strings.TrimSpace(write.SlugPrefix)), Published: true,
|
||||
CreatedBy: write.Actor,
|
||||
}
|
||||
if revision.ID == 1 {
|
||||
revision.ID = write.GiftID*1000 + 1
|
||||
}
|
||||
revision.Models = s.allocateCollectibleAttributes(write.Models, revision.ID)
|
||||
revision.Patterns = s.allocateCollectibleAttributes(write.Patterns, revision.ID)
|
||||
revision.Backdrops = s.allocateCollectibleAttributes(write.Backdrops, revision.ID)
|
||||
s.collectibles[write.GiftID] = revision
|
||||
gift := s.catalog[write.GiftID]
|
||||
gift.UpgradeStars = revision.UpgradeStars
|
||||
gift.UpgradeTotal = revision.SupplyTotal
|
||||
gift.UpgradeIssued = revision.Issued
|
||||
s.catalog[write.GiftID] = gift
|
||||
return cloneCollectibleRevision(revision), nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) allocateCollectibleAttributes(in []domain.StarGiftCollectibleAttribute, revisionID int64) []domain.StarGiftCollectibleAttribute {
|
||||
out := make([]domain.StarGiftCollectibleAttribute, len(in))
|
||||
for i, attribute := range in {
|
||||
s.nextAttributeID++
|
||||
attribute.ID = s.nextAttributeID
|
||||
attribute.CollectibleRevisionID = revisionID
|
||||
out[i] = cloneCollectibleAttribute(attribute)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ActiveCollectibleRevision(_ context.Context, giftID int64) (domain.StarGiftCollectibleRevision, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
revision, ok := s.collectibles[giftID]
|
||||
return cloneCollectibleRevision(revision), ok, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CollectibleAvailability(_ context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make(map[int64]domain.StarGiftCollectibleAvailability, len(giftIDs))
|
||||
for _, giftID := range giftIDs {
|
||||
revision, ok := s.collectibles[giftID]
|
||||
if !ok || !revision.Published {
|
||||
continue
|
||||
}
|
||||
out[giftID] = domain.StarGiftCollectibleAvailability{
|
||||
UpgradeStars: revision.UpgradeStars,
|
||||
SupplyTotal: revision.SupplyTotal,
|
||||
Issued: revision.Issued,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CollectibleAnimationJSON(_ context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
revision, ok := s.collectibles[giftID]
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
var attributes []domain.StarGiftCollectibleAttribute
|
||||
switch kind {
|
||||
case domain.StarGiftCollectibleModel:
|
||||
attributes = revision.Models
|
||||
case domain.StarGiftCollectiblePattern:
|
||||
attributes = revision.Patterns
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
for _, attribute := range attributes {
|
||||
if attribute.ID == attributeID && attribute.Animation != nil {
|
||||
return append([]byte(nil), attribute.Animation.JSON...), true, nil
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) UniqueBySlug(_ context.Context, slug string) (domain.UniqueStarGift, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
id, ok := s.uniqueBySlug[strings.ToLower(strings.TrimSpace(slug))]
|
||||
if !ok {
|
||||
return domain.UniqueStarGift{}, false, nil
|
||||
}
|
||||
unique, ok := s.uniqueByID[id]
|
||||
return unique, ok, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) UniqueByID(_ context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
unique, ok := s.uniqueByID[uniqueGiftID]
|
||||
return unique, ok, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) UniqueByIDs(_ context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make(map[int64]domain.UniqueStarGift, len(uniqueGiftIDs))
|
||||
for _, id := range uniqueGiftIDs {
|
||||
if gift, ok := s.uniqueByID[id]; ok {
|
||||
out[id] = gift
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) Create(_ context.Context, gift domain.SavedStarGift) (int64, error) {
|
||||
|
|
@ -37,6 +280,13 @@ func (s *StarGiftStore) Create(_ context.Context, gift domain.SavedStarGift) (in
|
|||
}
|
||||
|
||||
func (s *StarGiftStore) ListByOwner(_ context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) {
|
||||
return s.ListByOwnerFiltered(context.Background(), domain.SavedStarGiftFilter{
|
||||
Owner: owner, ExcludeUnsaved: excludeUnsaved, Offset: offset, Limit: limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ListByOwnerFiltered(_ context.Context, filter domain.SavedStarGiftFilter) (domain.SavedStarGiftPage, error) {
|
||||
owner, offset, limit := filter.Owner, filter.Offset, filter.Limit
|
||||
if !validStarGiftOwner(owner) {
|
||||
return domain.SavedStarGiftPage{}, nil
|
||||
}
|
||||
|
|
@ -50,7 +300,31 @@ func (s *StarGiftStore) ListByOwner(_ context.Context, owner domain.Peer, exclud
|
|||
if g.Owner != owner || g.Converted {
|
||||
continue
|
||||
}
|
||||
if excludeUnsaved && g.Unsaved {
|
||||
if filter.ExcludeUnsaved && g.Unsaved {
|
||||
continue
|
||||
}
|
||||
if filter.ExcludeSaved && !g.Unsaved {
|
||||
continue
|
||||
}
|
||||
if filter.ExcludeUnique && g.UniqueGiftID != 0 {
|
||||
continue
|
||||
}
|
||||
if filter.ExcludeUnlimited && g.UniqueGiftID == 0 {
|
||||
continue
|
||||
}
|
||||
upgradable := false
|
||||
if g.UniqueGiftID == 0 {
|
||||
if gift, ok := s.catalog[g.GiftID]; ok {
|
||||
upgradable = gift.UpgradeStars > 0 && gift.UpgradeIssued < gift.UpgradeTotal
|
||||
}
|
||||
}
|
||||
if filter.ExcludeUpgradable && upgradable {
|
||||
continue
|
||||
}
|
||||
if filter.ExcludeUnupgradable && !upgradable {
|
||||
continue
|
||||
}
|
||||
if filter.CollectionID > 0 && !containsInt(g.CollectionIDs, filter.CollectionID) {
|
||||
continue
|
||||
}
|
||||
matched = append(matched, g)
|
||||
|
|
@ -82,6 +356,37 @@ func (s *StarGiftStore) ListByOwner(_ context.Context, owner domain.Peer, exclud
|
|||
return page, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ResolveSavedIDs(_ context.Context, owner domain.Peer, refs []domain.SavedStarGiftRef) ([]int64, error) {
|
||||
if !validStarGiftOwner(owner) || len(refs) > domain.MaxStarGiftCollectionItems {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]int64, 0, len(refs))
|
||||
seen := make(map[int64]struct{}, len(refs))
|
||||
for _, ref := range refs {
|
||||
if ref.Owner != owner || !ref.Valid() {
|
||||
return nil, domain.ErrStarGiftNotFound
|
||||
}
|
||||
var id int64
|
||||
for _, gift := range s.gifts {
|
||||
if savedStarGiftMatchesRef(gift, ref) && !gift.Converted {
|
||||
id = gift.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == 0 {
|
||||
return nil, domain.ErrStarGiftNotFound
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) GetByRef(_ context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) {
|
||||
if !ref.Valid() {
|
||||
return domain.SavedStarGift{}, false, nil
|
||||
|
|
@ -134,19 +439,298 @@ func (s *StarGiftStore) MarkConverted(_ context.Context, ref domain.SavedStarGif
|
|||
defer s.mu.Unlock()
|
||||
for i := range s.gifts {
|
||||
if savedStarGiftMatchesRef(s.gifts[i], ref) {
|
||||
if s.gifts[i].UniqueGiftID != 0 {
|
||||
return domain.SavedStarGift{}, domain.ErrStarGiftAlreadyUpgraded
|
||||
}
|
||||
if s.gifts[i].Converted {
|
||||
return domain.SavedStarGift{}, domain.ErrStarGiftAlreadyConverted
|
||||
}
|
||||
s.gifts[i].Converted = true
|
||||
s.gifts[i].Unsaved = true
|
||||
s.gifts[i].PinnedOrder = 0
|
||||
for collectionIndex := range s.collections[ref.Owner] {
|
||||
collection := &s.collections[ref.Owner][collectionIndex]
|
||||
next := collection.GiftIDs[:0]
|
||||
for _, giftID := range collection.GiftIDs {
|
||||
if giftID != s.gifts[i].ID {
|
||||
next = append(next, giftID)
|
||||
}
|
||||
}
|
||||
collection.GiftIDs = next
|
||||
collection.Hash = domain.StarGiftCollectionHash(collection.Title, collection.GiftIDs)
|
||||
}
|
||||
s.refreshCollectionMembershipsLocked(ref.Owner)
|
||||
return s.gifts[i], nil
|
||||
}
|
||||
}
|
||||
return domain.SavedStarGift{}, domain.ErrStarGiftNotFound
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ListCollections(_ context.Context, owner domain.Peer) ([]domain.StarGiftCollection, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return cloneStarGiftCollections(s.collections[owner]), nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) CreateCollection(_ context.Context, owner domain.Peer, title string, savedGiftIDs []int64) (domain.StarGiftCollection, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if !validStarGiftOwner(owner) || title == "" || len([]rune(title)) > domain.MaxStarGiftCollectionTitleRunes {
|
||||
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.collections[owner]) >= domain.MaxStarGiftCollectionsPerPeer {
|
||||
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectionsFull
|
||||
}
|
||||
ids, err := s.validCollectionGiftIDsLocked(owner, savedGiftIDs)
|
||||
if err != nil {
|
||||
return domain.StarGiftCollection{}, err
|
||||
}
|
||||
s.nextCollectionID++
|
||||
collection := domain.StarGiftCollection{Owner: owner, CollectionID: s.nextCollectionID, Title: title, GiftIDs: ids, SortOrder: len(s.collections[owner])}
|
||||
collection.Hash = domain.StarGiftCollectionHash(collection.Title, collection.GiftIDs)
|
||||
s.collections[owner] = append(s.collections[owner], collection)
|
||||
s.refreshCollectionMembershipsLocked(owner)
|
||||
return collection, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) UpdateCollection(_ context.Context, owner domain.Peer, collectionID int, patch domain.StarGiftCollectionPatch) (domain.StarGiftCollection, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
collections := s.collections[owner]
|
||||
index := -1
|
||||
for i := range collections {
|
||||
if collections[i].CollectionID == collectionID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectionNotFound
|
||||
}
|
||||
collection := collections[index]
|
||||
if patch.Title != nil {
|
||||
title := strings.TrimSpace(*patch.Title)
|
||||
if title == "" || len([]rune(title)) > domain.MaxStarGiftCollectionTitleRunes {
|
||||
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
collection.Title = title
|
||||
}
|
||||
deleteSet := make(map[int64]struct{}, len(patch.DeleteIDs))
|
||||
for _, id := range patch.DeleteIDs {
|
||||
deleteSet[id] = struct{}{}
|
||||
}
|
||||
next := make([]int64, 0, len(collection.GiftIDs)+len(patch.AddIDs))
|
||||
for _, id := range collection.GiftIDs {
|
||||
if _, deleted := deleteSet[id]; !deleted {
|
||||
next = append(next, id)
|
||||
}
|
||||
}
|
||||
add, err := s.validCollectionGiftIDsLocked(owner, patch.AddIDs)
|
||||
if err != nil {
|
||||
return domain.StarGiftCollection{}, err
|
||||
}
|
||||
next = appendUniqueInt64(next, add...)
|
||||
if patch.Order != nil {
|
||||
order, err := s.validCollectionGiftIDsLocked(owner, patch.Order)
|
||||
if err != nil || !sameInt64Set(order, next) {
|
||||
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
next = order
|
||||
}
|
||||
if len(next) > domain.MaxStarGiftCollectionItems {
|
||||
return domain.StarGiftCollection{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
collection.GiftIDs = next
|
||||
collection.Hash = domain.StarGiftCollectionHash(collection.Title, collection.GiftIDs)
|
||||
collections[index] = collection
|
||||
s.collections[owner] = collections
|
||||
s.refreshCollectionMembershipsLocked(owner)
|
||||
return collection, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) DeleteCollection(_ context.Context, owner domain.Peer, collectionID int) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
collections := s.collections[owner]
|
||||
for i := range collections {
|
||||
if collections[i].CollectionID == collectionID {
|
||||
collections = append(collections[:i], collections[i+1:]...)
|
||||
for j := range collections {
|
||||
collections[j].SortOrder = j
|
||||
}
|
||||
s.collections[owner] = collections
|
||||
s.refreshCollectionMembershipsLocked(owner)
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ReorderCollections(_ context.Context, owner domain.Peer, collectionIDs []int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
collections := s.collections[owner]
|
||||
if len(collectionIDs) != len(collections) {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
byID := make(map[int]domain.StarGiftCollection, len(collections))
|
||||
for _, collection := range collections {
|
||||
byID[collection.CollectionID] = collection
|
||||
}
|
||||
next := make([]domain.StarGiftCollection, 0, len(collections))
|
||||
for order, id := range collectionIDs {
|
||||
collection, ok := byID[id]
|
||||
if !ok {
|
||||
return domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
delete(byID, id)
|
||||
collection.SortOrder = order
|
||||
next = append(next, collection)
|
||||
}
|
||||
s.collections[owner] = next
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) SetPinned(_ context.Context, owner domain.Peer, savedGiftIDs []int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ids, err := s.validCollectionGiftIDsLocked(owner, savedGiftIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
order := make(map[int64]int, len(ids))
|
||||
for i, id := range ids {
|
||||
order[id] = i + 1
|
||||
}
|
||||
for i := range s.gifts {
|
||||
if s.gifts[i].Owner == owner {
|
||||
s.gifts[i].PinnedOrder = order[s.gifts[i].ID]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshCollectionMembershipsLocked keeps the in-memory saved-gift projection
|
||||
// equivalent to the PostgreSQL join projection. Callers must hold s.mu.
|
||||
func (s *StarGiftStore) refreshCollectionMembershipsLocked(owner domain.Peer) {
|
||||
memberships := make(map[int64][]int)
|
||||
for _, collection := range s.collections[owner] {
|
||||
for _, giftID := range collection.GiftIDs {
|
||||
memberships[giftID] = append(memberships[giftID], collection.CollectionID)
|
||||
}
|
||||
}
|
||||
for i := range s.gifts {
|
||||
if s.gifts[i].Owner != owner {
|
||||
continue
|
||||
}
|
||||
s.gifts[i].CollectionIDs = append([]int(nil), memberships[s.gifts[i].ID]...)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) validCollectionGiftIDsLocked(owner domain.Peer, ids []int64) ([]int64, error) {
|
||||
if len(ids) > domain.MaxStarGiftCollectionItems {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
out := make([]int64, 0, len(ids))
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
valid := false
|
||||
for _, gift := range s.gifts {
|
||||
if gift.ID == id && gift.Owner == owner && !gift.Converted {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return nil, domain.ErrStarGiftNotFound
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func appendUniqueInt64(dst []int64, values ...int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(dst)+len(values))
|
||||
for _, id := range dst {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, id := range values {
|
||||
if _, ok := seen[id]; !ok {
|
||||
seen[id] = struct{}{}
|
||||
dst = append(dst, id)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func sameInt64Set(a, b []int64) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
seen := make(map[int64]int, len(a))
|
||||
for _, id := range a {
|
||||
seen[id]++
|
||||
}
|
||||
for _, id := range b {
|
||||
seen[id]--
|
||||
if seen[id] < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func cloneCollectibleAttribute(in domain.StarGiftCollectibleAttribute) domain.StarGiftCollectibleAttribute {
|
||||
out := in
|
||||
if in.Document != nil {
|
||||
document := *in.Document
|
||||
out.Document = &document
|
||||
}
|
||||
if in.Animation != nil {
|
||||
animation := *in.Animation
|
||||
animation.JSON = append([]byte(nil), in.Animation.JSON...)
|
||||
animation.TGS = append([]byte(nil), in.Animation.TGS...)
|
||||
animation.SHA256 = append([]byte(nil), in.Animation.SHA256...)
|
||||
out.Animation = &animation
|
||||
}
|
||||
if in.Blob != nil {
|
||||
blob := *in.Blob
|
||||
out.Blob = &blob
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneCollectibleRevision(in domain.StarGiftCollectibleRevision) domain.StarGiftCollectibleRevision {
|
||||
out := in
|
||||
clone := func(attributes []domain.StarGiftCollectibleAttribute) []domain.StarGiftCollectibleAttribute {
|
||||
copy := make([]domain.StarGiftCollectibleAttribute, len(attributes))
|
||||
for i, attribute := range attributes {
|
||||
copy[i] = cloneCollectibleAttribute(attribute)
|
||||
}
|
||||
return copy
|
||||
}
|
||||
out.Models = clone(in.Models)
|
||||
out.Patterns = clone(in.Patterns)
|
||||
out.Backdrops = clone(in.Backdrops)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStarGiftCollections(in []domain.StarGiftCollection) []domain.StarGiftCollection {
|
||||
out := make([]domain.StarGiftCollection, len(in))
|
||||
for i, collection := range in {
|
||||
out[i] = collection
|
||||
out[i].GiftIDs = append([]int64(nil), collection.GiftIDs...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validSavedStarGift(g domain.SavedStarGift) bool {
|
||||
if g.GiftID == 0 || !validStarGiftOwner(g.Owner) {
|
||||
if g.GiftID == 0 || g.RevisionID == 0 || !validStarGiftOwner(g.Owner) {
|
||||
return false
|
||||
}
|
||||
switch g.Owner.Type {
|
||||
|
|
|
|||
65
internal/store/memory/update_state_delivery_test.go
Normal file
65
internal/store/memory/update_state_delivery_test.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestUpdateStateDeliveredCommitSeparatesConfirmedAndObserved(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewUpdateStateStore()
|
||||
authKeyID := [8]byte{1}
|
||||
const userID int64 = 1001
|
||||
if err := store.ObserveClientState(ctx, authKeyID, userID, domain.UpdateState{Pts: 2, Date: 20}); err != nil {
|
||||
t.Fatalf("observe request: %v", err)
|
||||
}
|
||||
if _, found, err := store.Get(ctx, authKeyID, userID); err != nil || found {
|
||||
t.Fatalf("observed request fabricated confirmed state: found=%v err=%v", found, err)
|
||||
}
|
||||
if err := store.CommitDeliveredState(ctx, authKeyID, userID, domain.UpdateState{Pts: 5, Date: 50}, domain.UpdateStateCommitDeliveredOnly); err != nil {
|
||||
t.Fatalf("commit delivered: %v", err)
|
||||
}
|
||||
confirmed, found, err := store.Get(ctx, authKeyID, userID)
|
||||
if err != nil || !found || confirmed.Pts != 5 {
|
||||
t.Fatalf("confirmed = %+v/%v err=%v, want pts=5", confirmed, found, err)
|
||||
}
|
||||
observed, found := store.ObservedClientState(authKeyID, userID)
|
||||
if !found || observed.Pts != 2 {
|
||||
t.Fatalf("observed = %+v/%v, want pts=2", observed, found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStateBaselineCommitIsAtomicAndMonotonic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewUpdateStateStore()
|
||||
authKeyID := [8]byte{2}
|
||||
const userID int64 = 1002
|
||||
if err := store.CommitDeliveredState(ctx, authKeyID, userID, domain.UpdateState{Pts: 8, Date: 80}, domain.UpdateStateCommitDeliveredAndObservedBaseline); err != nil {
|
||||
t.Fatalf("commit baseline: %v", err)
|
||||
}
|
||||
if err := store.CommitDeliveredState(ctx, authKeyID, userID, domain.UpdateState{Pts: 3, Date: 30}, domain.UpdateStateCommitDeliveredAndObservedBaseline); err != nil {
|
||||
t.Fatalf("commit stale baseline: %v", err)
|
||||
}
|
||||
confirmed, _, _ := store.Get(ctx, authKeyID, userID)
|
||||
observed, _ := store.ObservedClientState(authKeyID, userID)
|
||||
if confirmed.Pts != 8 || observed.Pts != 8 {
|
||||
t.Fatalf("out-of-order baseline regressed state: confirmed=%+v observed=%+v", confirmed, observed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStateDeleteAuthKeyRemovesObservedOnlyRows(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewUpdateStateStore()
|
||||
authKeyID := [8]byte{3}
|
||||
if err := store.ObserveClientState(ctx, authKeyID, 1003, domain.UpdateState{Pts: 4}); err != nil {
|
||||
t.Fatalf("observe: %v", err)
|
||||
}
|
||||
if err := store.DeleteAuthKey(ctx, authKeyID); err != nil {
|
||||
t.Fatalf("delete auth key: %v", err)
|
||||
}
|
||||
if _, found := store.ObservedClientState(authKeyID, 1003); found {
|
||||
t.Fatal("observed-only row survived auth-key deletion")
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package memory
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
@ -195,27 +196,44 @@ func (s *UpdateStateStore) Save(_ context.Context, id [8]byte, userID int64, st
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *UpdateStateStore) CommitDeliveredState(_ context.Context, id [8]byte, userID int64, st domain.UpdateState, mode domain.UpdateStateCommitMode) error {
|
||||
if mode != domain.UpdateStateCommitDeliveredOnly && mode != domain.UpdateStateCommitDeliveredAndObservedBaseline {
|
||||
return fmt.Errorf("commit delivered update state: invalid mode %d", mode)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
key := updateStateKey{authKeyID: id, userID: userID}
|
||||
s.states[key] = monotonicUpdateState(s.states[key], st)
|
||||
if mode == domain.UpdateStateCommitDeliveredAndObservedBaseline {
|
||||
s.observed[key] = monotonicUpdateState(s.observed[key], st)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UpdateStateStore) ObserveClientState(_ context.Context, id [8]byte, userID int64, st domain.UpdateState) error {
|
||||
s.mu.Lock()
|
||||
key := updateStateKey{authKeyID: id, userID: userID}
|
||||
prev := s.observed[key]
|
||||
if st.Pts < prev.Pts {
|
||||
st.Pts = prev.Pts
|
||||
}
|
||||
if st.Qts < prev.Qts {
|
||||
st.Qts = prev.Qts
|
||||
}
|
||||
if st.Date < prev.Date {
|
||||
st.Date = prev.Date
|
||||
}
|
||||
if st.Seq < prev.Seq {
|
||||
st.Seq = prev.Seq
|
||||
}
|
||||
s.observed[key] = st
|
||||
s.observed[key] = monotonicUpdateState(s.observed[key], st)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func monotonicUpdateState(prev, next domain.UpdateState) domain.UpdateState {
|
||||
if next.Pts < prev.Pts {
|
||||
next.Pts = prev.Pts
|
||||
}
|
||||
if next.Qts < prev.Qts {
|
||||
next.Qts = prev.Qts
|
||||
}
|
||||
if next.Date < prev.Date {
|
||||
next.Date = prev.Date
|
||||
}
|
||||
if next.Seq < prev.Seq {
|
||||
next.Seq = prev.Seq
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// ObservedClientState 暴露给同包/服务测试验证 retention 安全水位;业务读路径仍用 Get。
|
||||
func (s *UpdateStateStore) ObservedClientState(id [8]byte, userID int64) (domain.UpdateState, bool) {
|
||||
s.mu.RLock()
|
||||
|
|
@ -240,6 +258,11 @@ func (s *UpdateStateStore) DeleteAuthKey(_ context.Context, id [8]byte) error {
|
|||
delete(s.observed, k)
|
||||
}
|
||||
}
|
||||
for k := range s.observed {
|
||||
if k.authKeyID == id {
|
||||
delete(s.observed, k)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue