merged with fixes

This commit is contained in:
onysd 2026-09-09 02:49:30 +03:00
parent a9e758b712
commit 2f1818d656
176 changed files with 9000 additions and 907 deletions

View file

@ -4,6 +4,9 @@ import (
"errors"
"strings"
"time"
"unicode"
"github.com/nyaruka/phonenumbers"
)
var (
@ -260,38 +263,105 @@ func MaskEmail(email string) string {
return name[:1] + "***" + name[len(name)-1:] + email[at:]
}
// NormalizePhone 仅保留手机号中的数字(与 users.phone 的存储形态一致)。全部被过滤
// 掉时返回原串,便于上层做 validPhone 拒绝。auth/account 两域共用同一规则避免漂移。
//
// Email-signup 合成号码EncodeEmailPhone 生成,"888" 前缀 + 至少一个字母)是唯一例外:
// 原样保留(仅 lower+trim不剥离字母——否则 DecodeEmailPhone 会因编码内容被剥空而
// 永远解不出邮箱。真实手机号恒为纯数字,不含字母,故这个判定不会误伤任何真实号码。
func NormalizePhone(phone string) string {
if IsEmailSignupPhone(phone) {
return strings.ToLower(strings.TrimSpace(phone))
}
// virtualLoginPhoneMinDigits/virtualLoginPhoneMaxDigits bound the "888"-prefixed
// virtual login identity range NormalizePhone accepts without going through
// libphonenumber (real E.164 numbers never start with 888). This is a login
// identity concept only -- distinct from, and independent of, ownership of any
// purchasable collectible-phone asset with the same digit shape.
const (
virtualLoginPhoneMinDigits = 7
virtualLoginPhoneMaxDigits = 15
)
// PhoneDigits removes presentation punctuation from a phone number. It is
// intentionally not an identity canonicalizer: callers that select accounts,
// issue codes, or persist users must use NormalizePhone and ValidPhone.
func PhoneDigits(phone string) string {
var b strings.Builder
b.Grow(len(phone))
seenDigit := false
seenPlus := false
for _, r := range phone {
if r >= '0' && r <= '9' {
switch {
case r >= '0' && r <= '9':
b.WriteRune(r)
seenDigit = true
case r == '+':
if seenPlus || seenDigit {
return ""
}
seenPlus = true
case unicode.IsSpace(r), r == '-', r == '(', r == ')', r == '.', r == '/':
// Presentation separators accepted by official clients and contact UIs.
default:
return ""
}
}
if b.Len() == 0 {
return phone
}
return b.String()
}
// ValidPhone 校验 NormalizePhone 后的持久化形态:真实手机号是 5-200 位纯数字;
// email-signup 合成号码额外允许小写字母EncodeEmailPhone 的转义字符集)。
// 上限与 users.phone 列宽一致;当前开发登录/改号链路不强制精确 E.164 长度,
// 但拒绝空串、非法字符和会截断的超长输入。
func ValidPhone(phone string) bool {
if len(phone) < 5 || len(phone) > 200 {
return false
}
// NormalizePhone returns the one persisted login identity. Virtual +888
// identities are independent of the collectible-phone registry and accept
// 7-15 canonical digits. Ordinary international numbers use E.164 digits
// without the leading '+'. Their parsing is deliberately country-aware so a
// national trunk prefix is removed only where the numbering plan says it is a
// prefix. For example, both +98 0998 167 9461 and +98 998 167 9461 become
// 989981679461, while Italy's significant leading zero in +39 02 ... is retained.
//
// Email-signup synthetic numbers (EncodeEmailPhone, "888" prefix plus at least
// one letter) are a separate exception, kept as-is (lower+trim only, no digit
// stripping) -- otherwise DecodeEmailPhone could never recover the email from
// an already letter-stripped value. A real phone is always pure digits, so
// this check never misclassifies one.
//
// IsPossibleNumber is the structural gate rather than IsValidNumber. It keeps
// syntactically possible reserved/test ranges usable without accepting local
// numbers that omit their country calling code or numbers outside E.164's
// length/plan metadata.
func NormalizePhone(phone string) string {
if IsEmailSignupPhone(phone) {
return strings.ToLower(strings.TrimSpace(phone))
}
digits := PhoneDigits(phone)
if digits == "" {
return ""
}
// Every syntactically valid +888 virtual number is an independent login
// identity; minting or owning the same collectible-phone value is not a
// prerequisite. users.phone therefore takes lookup precedence over any
// optional collectible alias registry.
if len(digits) >= virtualLoginPhoneMinDigits &&
len(digits) <= virtualLoginPhoneMaxDigits &&
strings.HasPrefix(digits, "888") {
return digits
}
// 42777 is the reserved, non-login phone of the built-in service identity.
// It predates the ordinary E.164 user invariant and remains resolvable only
// so auth can reject it as a system account instead of treating it as free.
if digits == OfficialSystemPhone {
return digits
}
number, err := phonenumbers.Parse("+"+digits, phonenumbers.UNKNOWN_REGION)
if err != nil || !phonenumbers.IsPossibleNumber(number) {
return ""
}
canonical := strings.TrimPrefix(phonenumbers.Format(number, phonenumbers.E164), "+")
if canonical == "" || len(canonical) > 15 {
return ""
}
return canonical
}
// ValidPhone reports whether phone is already in the persisted canonical form.
// Callers accepting user input normalize first, then validate, so equivalent
// international spellings converge before lookup, rate limiting, OTP delivery,
// and uniqueness checks. Email-signup synthetic numbers keep their own
// lower+trim canonical form (see NormalizePhone).
func ValidPhone(phone string) bool {
if IsEmailSignupPhone(phone) {
if len(phone) < 5 || len(phone) > 200 {
return false
}
for _, r := range phone {
if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') {
return false
@ -299,10 +369,6 @@ func ValidPhone(phone string) bool {
}
return true
}
for _, r := range phone {
if r < '0' || r > '9' {
return false
}
}
return true
canonical := NormalizePhone(phone)
return canonical != "" && canonical == phone
}