added config for virtual phone number

This commit is contained in:
onysd 2026-07-14 18:37:27 +03:00
parent aa2fc45bdd
commit 1eaa06d961
10 changed files with 268 additions and 31 deletions

View file

@ -3,6 +3,7 @@ package account
import (
"context"
"crypto/rand"
"encoding/binary"
"encoding/hex"
"fmt"
"strings"
@ -242,14 +243,18 @@ func (s *Service) phoneChangeCaller(ctx context.Context, userID int64, authKeyID
// collision-retry loop so a store failure can't spin forever.
const maxEmailSignupPhoneAttempts = 20
// assignEmailSignupDisplayPhone generates a short "888" display number for
// an email-signup account rebinding to a new email (see
// assignEmailSignupDisplayPhone generates a short display number for an
// email-signup account rebinding to a new email (see
// domain.NewEmailSignupDisplayPhone / auth.Service's SignUp counterpart),
// re-rolling on the astronomically unlikely collision with an existing
// account's phone.
func (s *Service) assignEmailSignupDisplayPhone(ctx context.Context) (string, error) {
prefix, err := randomEmailSignupPhonePrefix(s.emailSignupPhonePrefixes)
if err != nil {
return "", err
}
for i := 0; i < maxEmailSignupPhoneAttempts; i++ {
candidate, err := domain.NewEmailSignupDisplayPhone()
candidate, err := domain.NewEmailSignupDisplayPhone(prefix)
if err != nil {
return "", err
}
@ -262,6 +267,25 @@ func (s *Service) assignEmailSignupDisplayPhone(ctx context.Context) (string, er
return "", fmt.Errorf("assign email signup display phone: exhausted %d attempts", maxEmailSignupPhoneAttempts)
}
// randomEmailSignupPhonePrefix mirrors auth.Service's identical helper
// (unexported to each package, but must pick with the same fairness): pick
// one entry at random from prefixes, falling back to domain.EmailPhonePrefix
// ("888") when the list is empty.
func randomEmailSignupPhonePrefix(prefixes []string) (string, error) {
if len(prefixes) == 0 {
return domain.EmailPhonePrefix, nil
}
if len(prefixes) == 1 {
return prefixes[0], nil
}
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("pick email signup phone prefix: %w", err)
}
idx := binary.LittleEndian.Uint64(b[:]) % uint64(len(prefixes))
return prefixes[idx], nil
}
func phoneChangeHash() (string, error) {
var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil {

View file

@ -55,6 +55,10 @@ type Service struct {
// 验证码走 loginEmailSender 发到解码出的邮箱;非 888 号码一律拒绝——本服务器没有真实
// 短信通道,放行会让账号的邮箱身份绑定被绕过(见迁移前的密码找回验证码漏洞教训)。
emailSignupEnabled bool
// emailSignupPhonePrefixes 是改绑邮箱时随机挑选的账号展示号码号段前缀
// 列表(domain.NewEmailSignupDisplayPhone),与 auth.Service 的同名字段
// 同一份服务端配置来源。为空时退回默认 "888"。
emailSignupPhonePrefixes []string
}
// ServiceOption 调整 account 服务依赖。
@ -163,6 +167,14 @@ func WithEmailSignup(enabled bool) ServiceOption {
}
}
// WithEmailSignupPhonePrefixes 设置改绑邮箱时展示号码随机挑选的号段前缀列表
// (见 emailSignupPhonePrefixes 字段注释)。
func WithEmailSignupPhonePrefixes(prefixes []string) ServiceOption {
return func(s *Service) {
s.emailSignupPhonePrefixes = prefixes
}
}
// NewService 创建 account 服务。
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
s := &Service{

View file

@ -90,6 +90,11 @@ type Service struct {
// 在 sendCode 阶段直接解码出邮箱、复用登录邮箱同一投递通道发码,与
// loginEmailEnabled(手机号账号的第二验证渠道)是两条独立开关。
emailSignupEnabled bool
// emailSignupPhonePrefixes 是 SignUp 时随机挑选的账号展示号码号段前缀
// 列表(domain.NewEmailSignupDisplayPhone);与 emailSignupEnabled 本身
// 用的合成 wire 号码前缀(固定 "888",见 domain.EncodeEmailPhone)无关。
// 为空时退回默认 "888"。
emailSignupPhonePrefixes []string
// premiumGrantMonths 是新注册账号默认赠送的会员月数;0 表示关闭赠送。
premiumGrantMonths int
}
@ -194,6 +199,14 @@ func WithEmailSignup(enabled bool) Option {
}
}
// WithEmailSignupPhonePrefixes 设置 SignUp 展示号码随机挑选的号段前缀列表
// (见 emailSignupPhonePrefixes 字段注释)。
func WithEmailSignupPhonePrefixes(prefixes []string) Option {
return func(s *Service) {
s.emailSignupPhonePrefixes = prefixes
}
}
// NewService 创建登录服务。fixedCode 为开发固定验证码。
func NewService(users store.UserStore, auths store.AuthorizationStore, codes store.CodeStore, authKeys store.AuthKeyStore, tempKeys store.TempAuthKeyBindingStore, fixedCode string, opts ...Option) *Service {
s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute, codeMaxAttempts: 5, loginEmailCodeLength: 6}
@ -1510,8 +1523,12 @@ func normalizePhone(phone string) string {
const maxEmailSignupPhoneAttempts = 20
func (s *Service) assignEmailSignupDisplayPhone(ctx context.Context) (string, error) {
prefix, err := randomEmailSignupPhonePrefix(s.emailSignupPhonePrefixes)
if err != nil {
return "", err
}
for i := 0; i < maxEmailSignupPhoneAttempts; i++ {
candidate, err := domain.NewEmailSignupDisplayPhone()
candidate, err := domain.NewEmailSignupDisplayPhone(prefix)
if err != nil {
return "", err
}
@ -1524,6 +1541,26 @@ func (s *Service) assignEmailSignupDisplayPhone(ctx context.Context) (string, er
return "", fmt.Errorf("assign email signup display phone: exhausted %d attempts", maxEmailSignupPhoneAttempts)
}
// randomEmailSignupPhonePrefix picks one entry at random from prefixes,
// falling back to domain.EmailPhonePrefix ("888") when the list is empty —
// config validation requires a non-empty list whenever email signup is
// enabled, but store-agnostic callers (tests, WithEmailSignupPhonePrefixes
// simply never called) shouldn't crash for lack of one.
func randomEmailSignupPhonePrefix(prefixes []string) (string, error) {
if len(prefixes) == 0 {
return domain.EmailPhonePrefix, nil
}
if len(prefixes) == 1 {
return prefixes[0], nil
}
n, err := randomInt64()
if err != nil {
return "", err
}
idx := uint64(n) % uint64(len(prefixes))
return prefixes[idx], nil
}
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {

View file

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"hash/crc32"
"strings"
"sync"
"telesrv/internal/domain"
@ -64,10 +65,11 @@ const defaultAppConfigHash = 23 // 默认 app config 内容变更时必须递增
// (登录页/启动配置是高频握手路径)。运维改库需重启生效。timezones/emoji 等其余目录走
// internal/seed/catalog(go:embed 一次解析),本就在内存。
type Service struct {
appConfigs store.AppConfigStore
countries store.CountryStore
mapboxToken string
emailSignupEnable bool
appConfigs store.AppConfigStore
countries store.CountryStore
mapboxToken string
emailSignupEnable bool
emailSignupPhonePrefixes []string
appConfigOnce sync.Once
appConfigCache domain.AppConfig
@ -93,6 +95,16 @@ func WithEmailSignupEnable(enabled bool) Option {
}
}
// WithEmailSignupPhonePrefixes 下发 email_signup_phone_prefixes:账号展示号码
// (domain.NewEmailSignupDisplayPhone)随机挑选的号段前缀列表,纯信息性——当前
// 客户端并不需要读它就能工作(该号码全程由服务端生成/下发),暴露出来只是让
// 管理员改动列表天然对所有已适配客户端可见,不需要客户端升级。
func WithEmailSignupPhonePrefixes(prefixes []string) Option {
return func(s *Service) {
s.emailSignupPhonePrefixes = prefixes
}
}
// NewService 创建 help 服务。
func NewService(appConfigs store.AppConfigStore, countries store.CountryStore, opts ...Option) *Service {
s := &Service{appConfigs: appConfigs, countries: countries}
@ -104,15 +116,20 @@ func NewService(appConfigs store.AppConfigStore, countries store.CountryStore, o
return s
}
func defaultAppConfig(mapboxToken string, emailSignupEnable bool) domain.AppConfig {
jsonBytes := defaultAppConfigJSON(mapboxToken, emailSignupEnable)
return domain.AppConfig{Client: tdesktopClient, Hash: defaultAppConfigHashFor(mapboxToken, emailSignupEnable), JSON: jsonBytes}
func defaultAppConfig(mapboxToken string, emailSignupEnable bool, emailSignupPhonePrefixes []string) domain.AppConfig {
jsonBytes := defaultAppConfigJSON(mapboxToken, emailSignupEnable, emailSignupPhonePrefixes)
return domain.AppConfig{Client: tdesktopClient, Hash: defaultAppConfigHashFor(mapboxToken, emailSignupEnable, emailSignupPhonePrefixes), JSON: jsonBytes}
}
func defaultAppConfigJSON(mapboxToken string, emailSignupEnable bool) []byte {
func defaultAppConfigJSON(mapboxToken string, emailSignupEnable bool, emailSignupPhonePrefixes []string) []byte {
base := tdesktopDefaultAppConfigBase
if emailSignupEnable {
base += `,"email_signup_enabled":true`
if len(emailSignupPhonePrefixes) > 0 {
if prefixesJSON, err := json.Marshal(emailSignupPhonePrefixes); err == nil {
base += `,"email_signup_phone_prefixes":` + string(prefixesJSON)
}
}
}
if mapboxToken == "" {
return []byte(base + `}`)
@ -125,10 +142,13 @@ func defaultAppConfigJSON(mapboxToken string, emailSignupEnable bool) []byte {
return []byte(base + `,"tdesktop_config_map":{"maps":` + tokenJSON + `,"geo":` + tokenJSON + `,"bmaps":` + tokenJSON + `,"bgeo":` + tokenJSON + `}}`)
}
func defaultAppConfigHashFor(mapboxToken string, emailSignupEnable bool) int {
func defaultAppConfigHashFor(mapboxToken string, emailSignupEnable bool, emailSignupPhonePrefixes []string) int {
h := defaultAppConfigHash
if emailSignupEnable {
h += 1000003 // large odd offset so toggling the flag always changes the hash
if len(emailSignupPhonePrefixes) > 0 {
h += 1 + int(crc32.ChecksumIEEE([]byte(strings.Join(emailSignupPhonePrefixes, ",")))&0x3fffffff)
}
}
if mapboxToken == "" {
return h
@ -144,9 +164,9 @@ func (s *Service) GetAppConfig(ctx context.Context, hash int) (domain.AppConfig,
func (s *Service) loadAppConfig(ctx context.Context) domain.AppConfig {
if s == nil {
return defaultAppConfig("", false)
return defaultAppConfig("", false, nil)
}
defaultCfg := defaultAppConfig(s.mapboxToken, s.emailSignupEnable)
defaultCfg := defaultAppConfig(s.mapboxToken, s.emailSignupEnable, s.emailSignupPhonePrefixes)
s.appConfigOnce.Do(func() {
if s.appConfigs == nil {
s.appConfigCache = defaultCfg

View file

@ -0,0 +1,59 @@
package help
import (
"context"
"encoding/json"
"testing"
)
// TestAppConfigEmailSignupPhonePrefixes asserts email_signup_phone_prefixes
// is only present alongside email_signup_enabled=true, carries exactly the
// configured prefix list, and that changing the list bumps the hash so
// clients don't cache a stale list.
func TestAppConfigEmailSignupPhonePrefixes(t *testing.T) {
ctx := context.Background()
disabled := NewService(nil, nil)
cfg, _, err := disabled.GetAppConfig(ctx, 0)
if err != nil {
t.Fatalf("GetAppConfig (disabled): %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(cfg.JSON, &decoded); err != nil {
t.Fatalf("app config json invalid: %v", err)
}
if _, present := decoded["email_signup_phone_prefixes"]; present {
t.Fatalf("email_signup_phone_prefixes present while email signup disabled: %+v", decoded["email_signup_phone_prefixes"])
}
enabled := NewService(nil, nil,
WithEmailSignupEnable(true),
WithEmailSignupPhonePrefixes([]string{"888", "380", "373"}))
cfg2, _, err := enabled.GetAppConfig(ctx, 0)
if err != nil {
t.Fatalf("GetAppConfig (enabled): %v", err)
}
if err := json.Unmarshal(cfg2.JSON, &decoded); err != nil {
t.Fatalf("app config json invalid: %v", err)
}
rawPrefixes, ok := decoded["email_signup_phone_prefixes"].([]any)
if !ok || len(rawPrefixes) != 3 {
t.Fatalf("email_signup_phone_prefixes = %+v, want [888 380 373]", decoded["email_signup_phone_prefixes"])
}
for i, want := range []string{"888", "380", "373"} {
if rawPrefixes[i] != want {
t.Fatalf("email_signup_phone_prefixes[%d] = %v, want %q", i, rawPrefixes[i], want)
}
}
other := NewService(nil, nil,
WithEmailSignupEnable(true),
WithEmailSignupPhonePrefixes([]string{"888"}))
cfg3, _, err := other.GetAppConfig(ctx, 0)
if err != nil {
t.Fatalf("GetAppConfig (different prefixes): %v", err)
}
if cfg3.Hash == cfg2.Hash {
t.Fatalf("hash unchanged (%d) despite a different prefix list, clients would stay cached on the old list", cfg3.Hash)
}
}