feat: sync account freeze lifecycle

This commit is contained in:
A 2026-07-15 20:24:37 +08:00
parent 76bfc5100f
commit 47fcf0ea41
40 changed files with 1363 additions and 196 deletions

View file

@ -25,8 +25,8 @@ func TestServiceSendMessageHonorsSendPermissionGate(t *testing.T) {
ChannelID: 2001,
RandomID: 1,
Message: "blocked",
}); !errors.Is(err, domain.ErrUserSendRestricted) {
t.Fatalf("SendMessage err=%v, want ErrUserSendRestricted", err)
}); !errors.Is(err, domain.ErrUserFrozen) {
t.Fatalf("SendMessage err=%v, want ErrUserFrozen", err)
}
}
@ -79,8 +79,8 @@ func TestServiceSendMonoforumMessageHonorsSendPermissionGate(t *testing.T) {
SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
RandomID: 1,
Message: "blocked",
}); !errors.Is(err, domain.ErrUserSendRestricted) {
t.Fatalf("SendMonoforumMessage err=%v, want ErrUserSendRestricted", err)
}); !errors.Is(err, domain.ErrUserFrozen) {
t.Fatalf("SendMonoforumMessage err=%v, want ErrUserFrozen", err)
}
}
@ -133,7 +133,7 @@ func TestServiceMonoforumReplayPrecedesCurrentSendPermissionGate(t *testing.T) {
type channelDenySendChecker struct{}
func (channelDenySendChecker) CanSendMessages(context.Context, int64) error {
return domain.ErrUserSendRestricted
return domain.ErrUserFrozen
}
func (p testBotProfiles) BotInfo(_ context.Context, botUserID int64) (domain.BotProfile, bool, error) {

View file

@ -3,7 +3,9 @@ package help
import (
"context"
"encoding/json"
"fmt"
"hash/crc32"
"strconv"
"sync"
"telesrv/internal/domain"
@ -64,9 +66,10 @@ const defaultAppConfigHash = 23 // 默认 app config 内容变更时必须递增
// (登录页/启动配置是高频握手路径)。运维改库需重启生效。timezones/emoji 等其余目录走
// internal/seed/catalog(go:embed 一次解析),本就在内存。
type Service struct {
appConfigs store.AppConfigStore
countries store.CountryStore
mapboxToken string
appConfigs store.AppConfigStore
countries store.CountryStore
accountFreeze AccountFreezeProvider
mapboxToken string
appConfigOnce sync.Once
appConfigCache domain.AppConfig
@ -77,6 +80,18 @@ type Service struct {
// Option 配置 help 服务运行期默认目录。
type Option func(*Service)
// AccountFreezeProvider supplies account-specific read-only state without
// exposing protocol types to the help application service.
type AccountFreezeProvider interface {
AccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error)
}
func WithAccountFreezeProvider(provider AccountFreezeProvider) Option {
return func(s *Service) {
s.accountFreeze = provider
}
}
// WithMapboxToken 设置 TDesktop appConfig 与地图缩略图代理共用的 Mapbox token。
func WithMapboxToken(token string) Option {
return func(s *Service) {
@ -119,12 +134,69 @@ func defaultAppConfigHashFor(mapboxToken string) int {
return defaultAppConfigHash + 1 + int(crc32.ChecksumIEEE([]byte(mapboxToken))&0x3fffffff)
}
// GetAppConfig 返回 TDesktop app confighash 命中时返回 notModified。首次调用加载一次后缓存。
func (s *Service) GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, bool, error) {
// GetAppConfig returns the cached global app config plus an authenticated,
// per-account freeze overlay. The overlay owns its own deterministic hash so a
// FROZEN_METHOD_INVALID-triggered refresh can never be answered notModified.
func (s *Service) GetAppConfig(ctx context.Context, userID int64, hash int) (domain.AppConfig, bool, error) {
cfg := s.loadAppConfig(ctx)
var err error
cfg, err = s.accountAppConfig(ctx, userID, cfg)
if err != nil {
return domain.AppConfig{}, false, err
}
return cfg, hash != 0 && hash == cfg.Hash, nil
}
func (s *Service) accountAppConfig(ctx context.Context, userID int64, base domain.AppConfig) (domain.AppConfig, error) {
values := make(map[string]json.RawMessage)
if err := json.Unmarshal(base.JSON, &values); err != nil {
return domain.AppConfig{}, fmt.Errorf("decode base app config: %w", err)
}
changed := false
for _, key := range []string{"freeze_since_date", "freeze_until_date", "freeze_appeal_url"} {
if _, exists := values[key]; exists {
delete(values, key)
changed = true
}
}
if userID > 0 {
// DrKLO applies only keys present in the new JSON object and retains old
// SharedPreferences values for missing keys. Authenticated non-frozen
// accounts therefore need an explicit zero/empty triplet to converge after
// an unfreeze; merely omitting the overlay works in TDesktop but leaves
// Android frozen indefinitely. Unauthenticated config remains unscoped.
values["freeze_since_date"] = json.RawMessage("0")
values["freeze_until_date"] = json.RawMessage("0")
values["freeze_appeal_url"] = json.RawMessage(`""`)
changed = true
if s != nil && s.accountFreeze != nil {
freeze, found, err := s.accountFreeze.AccountFreeze(ctx, userID)
if err != nil {
return domain.AppConfig{}, fmt.Errorf("load account freeze: %w", err)
}
if found && freeze.Frozen {
values["freeze_since_date"] = json.RawMessage(strconv.FormatInt(freeze.Since.Unix(), 10))
values["freeze_until_date"] = json.RawMessage(strconv.FormatInt(freeze.Until.Unix(), 10))
appeal, _ := json.Marshal(freeze.AppealURL)
values["freeze_appeal_url"] = appeal
}
}
}
if !changed {
return base, nil
}
body, err := json.Marshal(values)
if err != nil {
return domain.AppConfig{}, fmt.Errorf("encode account app config: %w", err)
}
hashInput := append([]byte(strconv.Itoa(base.Hash)+"\x00"), body...)
overlayHash := int(crc32.ChecksumIEEE(hashInput) & 0x7fffffff)
if overlayHash == 0 || overlayHash == base.Hash {
overlayHash = base.Hash + 1
}
return domain.AppConfig{Client: base.Client, Hash: overlayHash, JSON: body}, nil
}
func (s *Service) loadAppConfig(ctx context.Context) domain.AppConfig {
if s == nil {
return defaultAppConfig("")

View file

@ -0,0 +1,133 @@
package help
import (
"context"
"encoding/json"
"testing"
"time"
"telesrv/internal/domain"
)
func TestAccountAppConfigFreezeOverlayIsUserScopedAndHashAware(t *testing.T) {
since := time.Date(2026, 7, 15, 1, 2, 3, 0, time.UTC)
until := since.Add(7 * 24 * time.Hour)
provider := &fakeAccountFreezeProvider{items: map[int64]domain.AccountFreeze{
1001: {UserID: 1001, Frozen: true, Since: since, Until: until, AppealURL: "https://appeals.example.test/1001"},
}}
svc := NewService(nil, nil, WithAccountFreezeProvider(provider))
normal, notModified, err := svc.GetAppConfig(context.Background(), 1002, 0)
if err != nil || notModified {
t.Fatalf("normal GetAppConfig = %+v notModified=%v err=%v", normal, notModified, err)
}
frozen, notModified, err := svc.GetAppConfig(context.Background(), 1001, normal.Hash)
if err != nil || notModified || frozen.Hash == normal.Hash {
t.Fatalf("frozen GetAppConfig = hash:%d normal:%d notModified=%v err=%v", frozen.Hash, normal.Hash, notModified, err)
}
assertFreezeConfig(t, frozen.JSON, since.Unix(), until.Unix(), "https://appeals.example.test/1001")
if _, notModified, err := svc.GetAppConfig(context.Background(), 1001, frozen.Hash); err != nil || !notModified {
t.Fatalf("frozen hash replay = notModified:%v err:%v", notModified, err)
}
provider.items[1001] = domain.AccountFreeze{
UserID: 1001,
Frozen: true,
Since: since,
Until: until.Add(24 * time.Hour),
AppealURL: "https://appeals.example.test/1001/review",
}
updated, notModified, err := svc.GetAppConfig(context.Background(), 1001, frozen.Hash)
if err != nil || notModified || updated.Hash == frozen.Hash {
t.Fatalf("updated freeze config = hash:%d old:%d notModified=%v err=%v", updated.Hash, frozen.Hash, notModified, err)
}
assertFreezeConfig(t, updated.JSON, since.Unix(), until.Add(24*time.Hour).Unix(), "https://appeals.example.test/1001/review")
other, notModified, err := svc.GetAppConfig(context.Background(), 1002, frozen.Hash)
if err != nil || notModified || other.Hash != normal.Hash {
t.Fatalf("other user = hash:%d notModified:%v err:%v", other.Hash, notModified, err)
}
assertClearedFreezeConfig(t, other.JSON)
unauthorized, _, err := svc.GetAppConfig(context.Background(), 0, 0)
if err != nil {
t.Fatal(err)
}
assertNoFreezeConfig(t, unauthorized.JSON)
provider.items[1001] = domain.AccountFreeze{UserID: 1001}
unfrozen, notModified, err := svc.GetAppConfig(context.Background(), 1001, updated.Hash)
if err != nil || notModified || unfrozen.Hash != normal.Hash {
t.Fatalf("unfreeze refresh = hash:%d notModified:%v err:%v", unfrozen.Hash, notModified, err)
}
assertClearedFreezeConfig(t, unfrozen.JSON)
}
func TestAuthenticatedAppConfigClearsPersistedFreezeWithoutProvider(t *testing.T) {
svc := NewService(nil, nil)
unauthorized, _, err := svc.GetAppConfig(context.Background(), 0, 0)
if err != nil {
t.Fatal(err)
}
assertNoFreezeConfig(t, unauthorized.JSON)
authenticated, notModified, err := svc.GetAppConfig(context.Background(), 1001, unauthorized.Hash)
if err != nil || notModified || authenticated.Hash == unauthorized.Hash {
t.Fatalf("authenticated clear config = hash:%d base:%d notModified:%v err:%v", authenticated.Hash, unauthorized.Hash, notModified, err)
}
assertClearedFreezeConfig(t, authenticated.JSON)
}
func TestAccountAppConfigStripsGlobalFreezeFields(t *testing.T) {
svc := NewService(nil, nil)
base := domain.AppConfig{Client: "tdesktop", Hash: 9, JSON: []byte(`{"quote_length_max":1024,"freeze_since_date":1,"freeze_until_date":2,"freeze_appeal_url":"https://wrong.example"}`)}
cfg, err := svc.accountAppConfig(context.Background(), 0, base)
if err != nil {
t.Fatal(err)
}
if cfg.Hash == base.Hash {
t.Fatal("stripped config reused base hash")
}
assertNoFreezeConfig(t, cfg.JSON)
}
func assertFreezeConfig(t *testing.T, body []byte, since, until int64, appealURL string) {
t.Helper()
var values map[string]any
if err := json.Unmarshal(body, &values); err != nil {
t.Fatal(err)
}
if values["freeze_since_date"] != float64(since) || values["freeze_until_date"] != float64(until) || values["freeze_appeal_url"] != appealURL {
t.Fatalf("freeze config = %#v", values)
}
}
func assertNoFreezeConfig(t *testing.T, body []byte) {
t.Helper()
var values map[string]any
if err := json.Unmarshal(body, &values); err != nil {
t.Fatal(err)
}
for _, key := range []string{"freeze_since_date", "freeze_until_date", "freeze_appeal_url"} {
if _, exists := values[key]; exists {
t.Fatalf("unexpected %s in config", key)
}
}
}
func assertClearedFreezeConfig(t *testing.T, body []byte) {
t.Helper()
var values map[string]any
if err := json.Unmarshal(body, &values); err != nil {
t.Fatal(err)
}
if values["freeze_since_date"] != float64(0) || values["freeze_until_date"] != float64(0) || values["freeze_appeal_url"] != "" {
t.Fatalf("freeze clear config = %#v", values)
}
}
type fakeAccountFreezeProvider struct {
items map[int64]domain.AccountFreeze
}
func (f *fakeAccountFreezeProvider) AccountFreeze(_ context.Context, userID int64) (domain.AccountFreeze, bool, error) {
freeze, found := f.items[userID]
return freeze, found, nil
}

View file

@ -11,14 +11,14 @@ import (
// premiumCanBuy()=!premium_purchase_blocked 耦合,置 true 会同时隐藏送礼入口;
// reactions_user_max_premium 必须与服务端 enforcement 档位一致。
func TestAppConfigPremiumKeys(t *testing.T) {
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0)
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0, 0)
if err != nil || notModified {
t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err)
}
if cfg.Hash != defaultAppConfigHash || cfg.Hash < 10 {
t.Fatalf("hash = %d, want defaultAppConfigHash(≥10)", cfg.Hash)
}
oldCfg, oldNotModified, err := (*Service)(nil).GetAppConfig(context.Background(), defaultAppConfigHash-1)
oldCfg, oldNotModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0, defaultAppConfigHash-1)
if err != nil || oldNotModified || oldCfg.Hash != defaultAppConfigHash {
t.Fatalf("GetAppConfig(old hash) = hash %d notModified %v err %v, want refreshed config", oldCfg.Hash, oldNotModified, err)
}
@ -93,7 +93,7 @@ func TestAppConfigPremiumKeys(t *testing.T) {
}
func TestAppConfigOmitsMapboxTokenByDefault(t *testing.T) {
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0)
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0, 0)
if err != nil || notModified {
t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err)
}
@ -108,14 +108,14 @@ func TestAppConfigOmitsMapboxTokenByDefault(t *testing.T) {
func TestAppConfigUsesConfiguredMapboxTokenAndHash(t *testing.T) {
svc := NewService(nil, nil, WithMapboxToken("pk.test-token"))
cfg, notModified, err := svc.GetAppConfig(context.Background(), 0)
cfg, notModified, err := svc.GetAppConfig(context.Background(), 0, 0)
if err != nil || notModified {
t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err)
}
if cfg.Hash == defaultAppConfigHash {
t.Fatalf("hash = %d, want token-specific hash", cfg.Hash)
}
if _, notModified, err := svc.GetAppConfig(context.Background(), cfg.Hash); err != nil || !notModified {
if _, notModified, err := svc.GetAppConfig(context.Background(), 0, cfg.Hash); err != nil || !notModified {
t.Fatalf("GetAppConfig(hash) = notModified %v err %v, want notModified", notModified, err)
}
var decoded map[string]any

View file

@ -98,7 +98,7 @@ func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain.
req.SenderUserID = userID
}
if req.SenderUserID != userID {
return domain.SendPrivateTextResult{}, domain.ErrUserSendRestricted
return domain.SendPrivateTextResult{}, domain.ErrAuthenticatedScopeInvalid
}
if req.RandomID != 0 && !req.IdempotencyPreflighted {
fingerprint, err := store.PrivateSendFingerprint(req)

View file

@ -21,8 +21,8 @@ func TestServiceSendPrivateTextHonorsSendPermissionGate(t *testing.T) {
RecipientUserID: 1002,
RandomID: 1,
Message: "blocked",
}); !errors.Is(err, domain.ErrUserSendRestricted) {
t.Fatalf("SendPrivateText err=%v, want ErrUserSendRestricted", err)
}); !errors.Is(err, domain.ErrUserFrozen) {
t.Fatalf("SendPrivateText err=%v, want ErrUserFrozen", err)
}
if store.sends != 0 {
t.Fatalf("store sends=%d, want 0", store.sends)
@ -71,8 +71,8 @@ func TestServiceForwardPrivateMessagesHonorsSendPermissionGate(t *testing.T) {
ToUserID: 1003,
MessageIDs: []int{1},
RandomIDs: []int64{2},
}); !errors.Is(err, domain.ErrUserSendRestricted) {
t.Fatalf("ForwardPrivateMessages err=%v, want ErrUserSendRestricted", err)
}); !errors.Is(err, domain.ErrUserFrozen) {
t.Fatalf("ForwardPrivateMessages err=%v, want ErrUserFrozen", err)
}
if store.forwards != 0 {
t.Fatalf("store forwards=%d, want 0", store.forwards)
@ -502,7 +502,7 @@ type projectionMessageStore struct {
type denySendChecker struct{}
func (denySendChecker) CanSendMessages(context.Context, int64) error {
return domain.ErrUserSendRestricted
return domain.ErrUserFrozen
}
type gateMessageStore struct {