chore: initial commit of ai site platform
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
102
platform/internal/userstore/creds.go
Normal file
102
platform/internal/userstore/creds.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const passwordAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
|
||||
const usernameAlphabet = "abcdefghijkmnopqrstuvwxyz23456789"
|
||||
const usernameFirst = "abcdefghijkmnopqrstuvwxyz"
|
||||
|
||||
// RandomPassword 生成可读随机密码(不含易混字符 0/O/1/l)。
|
||||
func RandomPassword(n int) (string, error) {
|
||||
if n < 8 {
|
||||
n = 12
|
||||
}
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := make([]byte, n)
|
||||
for i := range b {
|
||||
out[i] = passwordAlphabet[int(b[i])%len(passwordAlphabet)]
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// RandomUsername 生成随机登录名(字母开头,仅小写字母与数字,默认 10 位)。
|
||||
func RandomUsername() (string, error) {
|
||||
const n = 10
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := make([]byte, n)
|
||||
out[0] = usernameFirst[int(b[0])%len(usernameFirst)]
|
||||
for i := 1; i < n; i++ {
|
||||
out[i] = usernameAlphabet[int(b[i])%len(usernameAlphabet)]
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// AllocUniqueUsername 分配全局唯一随机用户名。
|
||||
func AllocUniqueUsername(ctx context.Context, exists func(context.Context, string) (bool, error)) (string, error) {
|
||||
var last error
|
||||
for i := 0; i < 16; i++ {
|
||||
u, err := RandomUsername()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ok, err := exists(ctx, u)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ok {
|
||||
last = fmt.Errorf("username collision")
|
||||
continue
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
if last != nil {
|
||||
return "", fmt.Errorf("无法生成唯一用户名: %v", last)
|
||||
}
|
||||
return "", fmt.Errorf("无法生成唯一用户名")
|
||||
}
|
||||
|
||||
// Deprecated: 保留测试兼容;新逻辑请用 RandomUsername / AllocUniqueUsername。
|
||||
func SuggestAdminUsername(slug string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(slug))
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
base := b.String()
|
||||
if base == "" {
|
||||
base = "co"
|
||||
}
|
||||
if len(base) > 20 {
|
||||
base = base[:20]
|
||||
}
|
||||
return base + "_adm"
|
||||
}
|
||||
|
||||
// Deprecated: 见 AllocUniqueUsername。
|
||||
func NextUsernameCandidate(base string, attempt int) string {
|
||||
base = strings.TrimSpace(base)
|
||||
if base == "" {
|
||||
base = "user"
|
||||
}
|
||||
if attempt <= 0 {
|
||||
return base
|
||||
}
|
||||
suffix, err := RandomPassword(4)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%s%d", base, attempt)
|
||||
}
|
||||
return fmt.Sprintf("%s_%s", base, strings.ToLower(suffix))
|
||||
}
|
||||
51
platform/internal/userstore/creds_test.go
Normal file
51
platform/internal/userstore/creds_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRandomUsernameUnique(t *testing.T) {
|
||||
seen := map[string]struct{}{}
|
||||
for i := 0; i < 20; i++ {
|
||||
u, err := RandomUsername()
|
||||
if err != nil || len(u) != 10 {
|
||||
t.Fatalf("got %q %v", u, err)
|
||||
}
|
||||
if u[0] < 'a' || u[0] > 'z' {
|
||||
t.Fatalf("must start with letter: %q", u)
|
||||
}
|
||||
seen[u] = struct{}{}
|
||||
}
|
||||
if len(seen) < 15 {
|
||||
t.Fatalf("expected diverse usernames, got %d", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMemberAndChangePassword(t *testing.T) {
|
||||
s := NewMemoryStore()
|
||||
ten, err := s.CreateTenant(context.Background(), "测试公司", "acme")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uname, err := AllocUniqueUsername(context.Background(), s.UsernameExists)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, plain, err := s.CreateMember(context.Background(), ten.TenantID, uname, "", "管理员", "管理员", 0)
|
||||
if err != nil || plain == "" {
|
||||
t.Fatalf("u=%v plain=%q err=%v", u, plain, err)
|
||||
}
|
||||
if _, err := s.Login(context.Background(), uname, plain); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ChangePassword(context.Background(), u.UserID, plain, "newpass99"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Login(context.Background(), uname, "newpass99"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ChangePassword(context.Background(), u.UserID, "wrong", "x"); err == nil {
|
||||
t.Fatal("expected old password check")
|
||||
}
|
||||
}
|
||||
34
platform/internal/userstore/phone.go
Normal file
34
platform/internal/userstore/phone.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var chinaMobileRe = regexp.MustCompile(`^1[3-9]\d{9}$`)
|
||||
|
||||
// NormalizePhone 规范化中国大陆手机号(去空格、去 +86/86 前缀)。
|
||||
func NormalizePhone(raw string) (string, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
s = strings.ReplaceAll(s, "-", "")
|
||||
if strings.HasPrefix(s, "+86") {
|
||||
s = s[3:]
|
||||
} else if strings.HasPrefix(s, "86") && len(s) == 13 {
|
||||
s = s[2:]
|
||||
}
|
||||
if s == "" {
|
||||
return "", fmt.Errorf("请填写手机号")
|
||||
}
|
||||
if !chinaMobileRe.MatchString(s) {
|
||||
return "", fmt.Errorf("手机号格式不正确(需 11 位大陆号)")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// LooksLikePhone 判断登录账号是否按手机号解析。
|
||||
func LooksLikePhone(raw string) bool {
|
||||
_, err := NormalizePhone(raw)
|
||||
return err == nil
|
||||
}
|
||||
38
platform/internal/userstore/phone_test.go
Normal file
38
platform/internal/userstore/phone_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package userstore
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizePhone(t *testing.T) {
|
||||
ok, err := NormalizePhone("+86 138-0013-8000")
|
||||
if err != nil || ok != "13800138000" {
|
||||
t.Fatalf("got %q %v", ok, err)
|
||||
}
|
||||
if _, err := NormalizePhone("12345"); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !LooksLikePhone("13912345678") {
|
||||
t.Fatal("should look like phone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginByPhone(t *testing.T) {
|
||||
s := NewMemoryStore()
|
||||
ten, err := s.CreateTenant(nil, "测", "ph")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, plain, err := s.CreateMember(nil, ten.TenantID, "abcdefgxyz", "pass1234", "测", "管理员", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.BindPhone(nil, u.UserID, "13900001111"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.Login(nil, "13900001111", plain)
|
||||
if err != nil || got.Username != "abcdefgxyz" {
|
||||
t.Fatalf("login by phone: %v %#v", err, got)
|
||||
}
|
||||
if _, err := s.Login(nil, "abcdefgxyz", plain); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
1536
platform/internal/userstore/store.go
Normal file
1536
platform/internal/userstore/store.go
Normal file
File diff suppressed because it is too large
Load Diff
77
platform/internal/userstore/tenant_slug.go
Normal file
77
platform/internal/userstore/tenant_slug.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var tenantSlugRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,31}$`)
|
||||
|
||||
// 路径段保留字:不可用作公司 slug(与后续 /{slug}/ 路由冲突)
|
||||
var reservedTenantSlugs = map[string]struct{}{
|
||||
"api": {}, "ai": {}, "gateway": {}, "platform": {}, "_platform": {},
|
||||
"admin": {}, "assets": {}, "static": {}, "www": {}, "m": {}, "public": {},
|
||||
"health": {}, "ops": {}, "console": {}, "login": {}, "register": {},
|
||||
"favicon.ico": {}, "robots.txt": {},
|
||||
}
|
||||
|
||||
// NormalizeTenantSlug 规范化公司路径 slug(全局唯一,用于 www.yuxinda.com/{slug}/)。
|
||||
func NormalizeTenantSlug(raw string) (string, error) {
|
||||
s := strings.TrimSpace(strings.ToLower(raw))
|
||||
s = strings.ReplaceAll(s, "_", "-")
|
||||
s = strings.ReplaceAll(s, " ", "-")
|
||||
var b strings.Builder
|
||||
prevDash := false
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
prevDash = false
|
||||
continue
|
||||
}
|
||||
if r == '-' {
|
||||
if b.Len() == 0 || prevDash {
|
||||
continue
|
||||
}
|
||||
b.WriteByte('-')
|
||||
prevDash = true
|
||||
continue
|
||||
}
|
||||
// 跳过其它字符(含中文);中文名需用户另填 slug
|
||||
}
|
||||
s = strings.Trim(b.String(), "-")
|
||||
if s == "" {
|
||||
return "", fmt.Errorf("公司路径 slug 不能为空(请用英文/数字,如 aaa)")
|
||||
}
|
||||
if !tenantSlugRe.MatchString(s) {
|
||||
return "", fmt.Errorf("slug 须为 2–32 位,小写字母开头,仅含 a-z / 0-9 / -")
|
||||
}
|
||||
if _, bad := reservedTenantSlugs[s]; bad {
|
||||
return "", fmt.Errorf("slug %q 为系统保留字", s)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// SuggestTenantSlug 从公司名生成候选(中文名可能得不到可用 slug,需人工填写)。
|
||||
func SuggestTenantSlug(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
// 纯 ASCII 名:直接规范化
|
||||
ascii := true
|
||||
for _, r := range name {
|
||||
if r > unicode.MaxASCII {
|
||||
ascii = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ascii {
|
||||
s, err := NormalizeTenantSlug(name)
|
||||
if err == nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
19
platform/internal/userstore/tenant_slug_test.go
Normal file
19
platform/internal/userstore/tenant_slug_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package userstore
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeTenantSlug(t *testing.T) {
|
||||
ok, err := NormalizeTenantSlug("Aaa-01")
|
||||
if err != nil || ok != "aaa-01" {
|
||||
t.Fatalf("got %q %v", ok, err)
|
||||
}
|
||||
if _, err := NormalizeTenantSlug("api"); err == nil {
|
||||
t.Fatal("api should be reserved")
|
||||
}
|
||||
if _, err := NormalizeTenantSlug("1abc"); err == nil {
|
||||
t.Fatal("must start with letter")
|
||||
}
|
||||
if SuggestTenantSlug("Hello World") != "hello-world" {
|
||||
t.Fatalf("suggest ascii")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user