Enable Binding-scoped agent push/pull, empty-table schema ensure, SyncPage inspect/drop-table, default module import, and agent-bound publish docs from the 宇恒联调意见. Co-authored-by: Cursor <cursoragent@cursor.com>
1542 lines
43 KiB
Go
1542 lines
43 KiB
Go
package userstore
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"golang.org/x/crypto/bcrypt"
|
||
|
||
"aijianzhan/platform/internal/authx"
|
||
)
|
||
|
||
const (
|
||
StatusPending = "pending"
|
||
StatusActive = "active"
|
||
RolePending = authx.Role待加入
|
||
)
|
||
|
||
type User struct {
|
||
UserID int64
|
||
TenantID int64 // 0 = 未加入任何租户
|
||
OrgUnitID int64 // 0 = 未绑定组织(可见租户全量,由角色决定)
|
||
Username string
|
||
Phone string // 绑定手机号,可作登录账号
|
||
UsernameLoginDisabled bool // 已绑手机时可禁用「用户名+密码」登录
|
||
DisplayName string
|
||
Role string
|
||
Status string
|
||
CreatedAt time.Time
|
||
}
|
||
|
||
func (u *User) HasTenant() bool {
|
||
return u != nil && u.TenantID > 0 && u.Status == StatusActive && u.Role != RolePending
|
||
}
|
||
|
||
func (u *User) IsPlatformAdmin() bool {
|
||
return u != nil && authx.IsPlatformAdmin(u.Role)
|
||
}
|
||
|
||
type TenantInfo struct {
|
||
TenantID int64 `json:"tenant_id"`
|
||
Name string `json:"name"`
|
||
Slug string `json:"slug"` // 全局唯一路径前缀,如 aaa → /aaa/
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UserCount int64 `json:"user_count"`
|
||
AppCount int64 `json:"app_count"`
|
||
}
|
||
|
||
type Store interface {
|
||
Register(ctx context.Context, username, password, displayName string) (*User, error)
|
||
EnsureBootstrapOwner(ctx context.Context, username, password, displayName, companyName string) (*User, error)
|
||
EnsurePlatformAdmin(ctx context.Context, username, password, displayName string) (*User, error)
|
||
Login(ctx context.Context, username, password string) (*User, error)
|
||
GetByID(ctx context.Context, userID int64) (*User, error)
|
||
GetByPhone(ctx context.Context, phone string) (*User, error)
|
||
BindPhone(ctx context.Context, userID int64, phone string) (*User, error)
|
||
SetUsernameLoginDisabled(ctx context.Context, userID int64, disabled bool) (*User, error)
|
||
PhoneExists(ctx context.Context, phone string, excludeUserID int64) (bool, error)
|
||
JoinTenant(ctx context.Context, userID, tenantID int64, role string, orgUnitID int64) (*User, error)
|
||
CreateTenantAsOwner(ctx context.Context, userID int64, tenantName string) (*User, error)
|
||
SetOrgUnit(ctx context.Context, userID, tenantID, orgUnitID int64) (*User, error)
|
||
ListTenants(ctx context.Context) ([]TenantInfo, error)
|
||
CreateTenant(ctx context.Context, name, slug string) (*TenantInfo, error)
|
||
GetTenant(ctx context.Context, tenantID int64) (*TenantInfo, error)
|
||
GetTenantBySlug(ctx context.Context, slug string) (*TenantInfo, error)
|
||
UpdateTenant(ctx context.Context, tenantID int64, name, slug string) (*TenantInfo, error)
|
||
RenameTenantIf(ctx context.Context, oldName, newName string) error
|
||
EnsureTenantSlugs(ctx context.Context) error
|
||
ListMembers(ctx context.Context, tenantID int64) ([]User, error)
|
||
UpdateMember(ctx context.Context, tenantID, userID int64, role string, orgUnitID int64, status string) (*User, error)
|
||
// CreateMember 在指定公司直接创建登录账号;password 为空则随机生成。返回明文密码(仅此一次)。
|
||
CreateMember(ctx context.Context, tenantID int64, username, password, displayName, role string, orgUnitID int64) (*User, string, error)
|
||
UsernameExists(ctx context.Context, username string) (bool, error)
|
||
// ChangePassword 校验旧密码后设置新密码(用户自行改密)。
|
||
ChangePassword(ctx context.Context, userID int64, oldPassword, newPassword string) error
|
||
// SetPassword 管理员强制设密;password 为空则随机生成。返回明文密码。
|
||
SetPassword(ctx context.Context, userID int64, password string) (string, error)
|
||
}
|
||
|
||
type MemoryStore struct {
|
||
mu sync.Mutex
|
||
users map[string]*memUser
|
||
byID map[int64]*memUser
|
||
tenants map[int64]*TenantInfo
|
||
seqUser int64
|
||
seqTen int64
|
||
}
|
||
|
||
type memUser struct {
|
||
User
|
||
Hash string
|
||
}
|
||
|
||
func NewMemoryStore() *MemoryStore {
|
||
return &MemoryStore{
|
||
users: map[string]*memUser{},
|
||
byID: map[int64]*memUser{},
|
||
tenants: map[int64]*TenantInfo{},
|
||
}
|
||
}
|
||
|
||
func (s *MemoryStore) Register(_ context.Context, username, password, displayName string) (*User, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if _, ok := s.users[username]; ok {
|
||
return nil, fmt.Errorf("username already exists")
|
||
}
|
||
if len(username) < 3 || len(password) < 6 {
|
||
return nil, fmt.Errorf("username>=3 and password>=6 required")
|
||
}
|
||
hash, err := hashPassword(password)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
name := displayName
|
||
if name == "" {
|
||
name = username
|
||
}
|
||
s.seqUser++
|
||
u := &memUser{
|
||
User: User{
|
||
UserID: s.seqUser, TenantID: 0, Username: username,
|
||
DisplayName: name, Role: RolePending, Status: StatusPending, CreatedAt: time.Now().UTC(),
|
||
},
|
||
Hash: hash,
|
||
}
|
||
s.users[username] = u
|
||
s.byID[u.UserID] = u
|
||
cp := u.User
|
||
return &cp, nil
|
||
}
|
||
|
||
func (s *MemoryStore) EnsureBootstrapOwner(_ context.Context, username, password, displayName, companyName string) (*User, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if existing, ok := s.users[username]; ok {
|
||
cp := existing.User
|
||
return &cp, nil
|
||
}
|
||
hash, err := hashPassword(password)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
name := displayName
|
||
if name == "" {
|
||
name = username
|
||
}
|
||
tenName := strings.TrimSpace(companyName)
|
||
if tenName == "" {
|
||
tenName = name
|
||
}
|
||
s.seqTen++
|
||
s.seqUser++
|
||
u := &memUser{
|
||
User: User{
|
||
UserID: s.seqUser, TenantID: s.seqTen, Username: username,
|
||
DisplayName: name, Role: authx.Role管理员, Status: StatusActive, CreatedAt: time.Now().UTC(),
|
||
},
|
||
Hash: hash,
|
||
}
|
||
s.users[username] = u
|
||
s.byID[u.UserID] = u
|
||
s.tenants[s.seqTen] = &TenantInfo{TenantID: s.seqTen, Name: tenName, Slug: "demo", CreatedAt: time.Now().UTC()}
|
||
cp := u.User
|
||
return &cp, nil
|
||
}
|
||
|
||
func (s *MemoryStore) EnsurePlatformAdmin(_ context.Context, username, password, displayName string) (*User, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
hash, err := hashPassword(password)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
name := displayName
|
||
if name == "" {
|
||
name = username
|
||
}
|
||
if existing, ok := s.users[username]; ok {
|
||
existing.Hash = hash
|
||
existing.Role = authx.Role超级管理员
|
||
existing.Status = StatusActive
|
||
existing.TenantID = 0
|
||
existing.OrgUnitID = 0
|
||
if name != "" {
|
||
existing.DisplayName = name
|
||
}
|
||
cp := existing.User
|
||
return &cp, nil
|
||
}
|
||
s.seqUser++
|
||
u := &memUser{
|
||
User: User{
|
||
UserID: s.seqUser, TenantID: 0, Username: username,
|
||
DisplayName: name, Role: authx.Role超级管理员, Status: StatusActive, CreatedAt: time.Now().UTC(),
|
||
},
|
||
Hash: hash,
|
||
}
|
||
s.users[username] = u
|
||
s.byID[u.UserID] = u
|
||
cp := u.User
|
||
return &cp, nil
|
||
}
|
||
|
||
func (s *MemoryStore) ListTenants(_ context.Context) ([]TenantInfo, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
out := make([]TenantInfo, 0, len(s.tenants))
|
||
for _, t := range s.tenants {
|
||
info := *t
|
||
var uc int64
|
||
for _, u := range s.users {
|
||
if u.TenantID == t.TenantID && u.Status == StatusActive {
|
||
uc++
|
||
}
|
||
}
|
||
info.UserCount = uc
|
||
out = append(out, info)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *MemoryStore) GetTenant(_ context.Context, tenantID int64) (*TenantInfo, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
t, ok := s.tenants[tenantID]
|
||
if !ok {
|
||
return nil, fmt.Errorf("tenant not found")
|
||
}
|
||
cp := *t
|
||
return &cp, nil
|
||
}
|
||
|
||
func (s *MemoryStore) CreateTenant(_ context.Context, name, slug string) (*TenantInfo, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return nil, fmt.Errorf("name required")
|
||
}
|
||
if strings.TrimSpace(slug) == "" {
|
||
slug = SuggestTenantSlug(name)
|
||
}
|
||
ns, err := NormalizeTenantSlug(slug)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, t := range s.tenants {
|
||
if t.Slug == ns {
|
||
return nil, fmt.Errorf("slug already exists: %s", ns)
|
||
}
|
||
}
|
||
s.seqTen++
|
||
t := &TenantInfo{TenantID: s.seqTen, Name: name, Slug: ns, CreatedAt: time.Now().UTC()}
|
||
s.tenants[s.seqTen] = t
|
||
cp := *t
|
||
return &cp, nil
|
||
}
|
||
|
||
func (s *MemoryStore) UpdateTenant(_ context.Context, tenantID int64, name, slug string) (*TenantInfo, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
t, ok := s.tenants[tenantID]
|
||
if !ok {
|
||
return nil, fmt.Errorf("tenant not found")
|
||
}
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return nil, fmt.Errorf("name required")
|
||
}
|
||
t.Name = name
|
||
if strings.TrimSpace(slug) != "" {
|
||
ns, err := NormalizeTenantSlug(slug)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for id, other := range s.tenants {
|
||
if id != tenantID && other.Slug == ns {
|
||
return nil, fmt.Errorf("slug already exists: %s", ns)
|
||
}
|
||
}
|
||
t.Slug = ns
|
||
}
|
||
cp := *t
|
||
return &cp, nil
|
||
}
|
||
|
||
func (s *MemoryStore) GetTenantBySlug(_ context.Context, slug string) (*TenantInfo, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
ns, err := NormalizeTenantSlug(slug)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, t := range s.tenants {
|
||
if t.Slug == ns {
|
||
cp := *t
|
||
return &cp, nil
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("tenant not found")
|
||
}
|
||
|
||
func (s *MemoryStore) EnsureTenantSlugs(_ context.Context) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
used := map[string]struct{}{}
|
||
for _, t := range s.tenants {
|
||
if t.Slug != "" {
|
||
used[t.Slug] = struct{}{}
|
||
}
|
||
}
|
||
for _, t := range s.tenants {
|
||
if t.Slug != "" {
|
||
continue
|
||
}
|
||
base := SuggestTenantSlug(t.Name)
|
||
if base == "" {
|
||
base = fmt.Sprintf("t%d", t.TenantID)
|
||
}
|
||
cand := base
|
||
for i := 2; ; i++ {
|
||
if _, ok := used[cand]; !ok {
|
||
if _, err := NormalizeTenantSlug(cand); err == nil {
|
||
break
|
||
}
|
||
}
|
||
cand = fmt.Sprintf("%s-%d", base, i)
|
||
if i > 100 {
|
||
cand = fmt.Sprintf("t%d", t.TenantID)
|
||
break
|
||
}
|
||
}
|
||
ns, err := NormalizeTenantSlug(cand)
|
||
if err != nil {
|
||
ns = fmt.Sprintf("t%d", t.TenantID)
|
||
}
|
||
t.Slug = ns
|
||
used[ns] = struct{}{}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *MemoryStore) ListMembers(_ context.Context, tenantID int64) ([]User, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
out := make([]User, 0)
|
||
for _, u := range s.users {
|
||
if u.TenantID == tenantID && !authx.IsPlatformAdmin(u.Role) {
|
||
cp := u.User
|
||
cp.Role = authx.NormalizeRole(cp.Role)
|
||
out = append(out, cp)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *MemoryStore) UsernameExists(_ context.Context, username string) (bool, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
_, ok := s.users[strings.TrimSpace(username)]
|
||
return ok, nil
|
||
}
|
||
|
||
func (s *MemoryStore) CreateMember(_ context.Context, tenantID int64, username, password, displayName, role string, orgUnitID int64) (*User, string, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if tenantID <= 0 {
|
||
return nil, "", fmt.Errorf("tenant required")
|
||
}
|
||
if _, ok := s.tenants[tenantID]; !ok {
|
||
return nil, "", fmt.Errorf("tenant not found")
|
||
}
|
||
username = strings.TrimSpace(username)
|
||
if len(username) < 3 {
|
||
return nil, "", fmt.Errorf("username>=3 required")
|
||
}
|
||
if _, ok := s.users[username]; ok {
|
||
return nil, "", fmt.Errorf("username already exists")
|
||
}
|
||
plain := strings.TrimSpace(password)
|
||
var err error
|
||
if plain == "" {
|
||
plain, err = RandomPassword(12)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
}
|
||
if len(plain) < 6 {
|
||
return nil, "", fmt.Errorf("password>=6 required")
|
||
}
|
||
if !authx.ValidPlatformRole(role) {
|
||
return nil, "", fmt.Errorf("invalid role")
|
||
}
|
||
role = authx.NormalizeRole(role)
|
||
hash, err := hashPassword(plain)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
name := strings.TrimSpace(displayName)
|
||
if name == "" {
|
||
name = username
|
||
}
|
||
s.seqUser++
|
||
u := &memUser{
|
||
User: User{
|
||
UserID: s.seqUser, TenantID: tenantID, OrgUnitID: orgUnitID, Username: username,
|
||
DisplayName: name, Role: role, Status: StatusActive, CreatedAt: time.Now().UTC(),
|
||
},
|
||
Hash: hash,
|
||
}
|
||
s.users[username] = u
|
||
s.byID[u.UserID] = u
|
||
if t := s.tenants[tenantID]; t != nil {
|
||
t.UserCount++
|
||
}
|
||
cp := u.User
|
||
return &cp, plain, nil
|
||
}
|
||
|
||
func (s *MemoryStore) ChangePassword(_ context.Context, userID int64, oldPassword, newPassword string) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
u, ok := s.byID[userID]
|
||
if !ok {
|
||
return fmt.Errorf("user not found")
|
||
}
|
||
if bcrypt.CompareHashAndPassword([]byte(u.Hash), []byte(oldPassword)) != nil {
|
||
return fmt.Errorf("旧密码不正确")
|
||
}
|
||
newPassword = strings.TrimSpace(newPassword)
|
||
if len(newPassword) < 6 {
|
||
return fmt.Errorf("password>=6 required")
|
||
}
|
||
hash, err := hashPassword(newPassword)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
u.Hash = hash
|
||
return nil
|
||
}
|
||
|
||
func (s *MemoryStore) SetPassword(_ context.Context, userID int64, password string) (string, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
u, ok := s.byID[userID]
|
||
if !ok {
|
||
return "", fmt.Errorf("user not found")
|
||
}
|
||
plain := strings.TrimSpace(password)
|
||
var err error
|
||
if plain == "" {
|
||
plain, err = RandomPassword(12)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
}
|
||
if len(plain) < 6 {
|
||
return "", fmt.Errorf("password>=6 required")
|
||
}
|
||
hash, err := hashPassword(plain)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
u.Hash = hash
|
||
return plain, nil
|
||
}
|
||
|
||
func (s *MemoryStore) UpdateMember(_ context.Context, tenantID, userID int64, role string, orgUnitID int64, status string) (*User, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
u, ok := s.byID[userID]
|
||
if !ok || u.TenantID != tenantID {
|
||
return nil, fmt.Errorf("member not found")
|
||
}
|
||
if authx.IsPlatformAdmin(u.Role) {
|
||
return nil, fmt.Errorf("cannot edit platform admin")
|
||
}
|
||
if role != "" {
|
||
if !authx.ValidPlatformRole(role) {
|
||
return nil, fmt.Errorf("invalid role")
|
||
}
|
||
u.Role = authx.NormalizeRole(role)
|
||
}
|
||
if status == StatusActive || status == StatusPending || status == "disabled" {
|
||
u.Status = status
|
||
}
|
||
u.OrgUnitID = orgUnitID
|
||
cp := u.User
|
||
return &cp, nil
|
||
}
|
||
|
||
func (s *MemoryStore) Login(_ context.Context, account, password string) (*User, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
account = strings.TrimSpace(account)
|
||
u, ok := s.users[account]
|
||
if !ok {
|
||
if phone, err := NormalizePhone(account); err == nil {
|
||
for _, cand := range s.users {
|
||
if cand.Phone == phone {
|
||
u, ok = cand, true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if !ok || bcrypt.CompareHashAndPassword([]byte(u.Hash), []byte(password)) != nil {
|
||
return nil, fmt.Errorf("invalid username or password")
|
||
}
|
||
cp := u.User
|
||
cp.Role = authx.NormalizeRole(cp.Role)
|
||
if cp.Status == "disabled" {
|
||
return nil, fmt.Errorf("账号已停用")
|
||
}
|
||
return &cp, nil
|
||
}
|
||
|
||
func (s *MemoryStore) GetByID(_ context.Context, userID int64) (*User, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
u, ok := s.byID[userID]
|
||
if !ok {
|
||
return nil, fmt.Errorf("user not found")
|
||
}
|
||
cp := u.User
|
||
cp.Role = authx.NormalizeRole(cp.Role)
|
||
return &cp, nil
|
||
}
|
||
|
||
func (s *MemoryStore) GetByPhone(_ context.Context, phone string) (*User, error) {
|
||
ns, err := NormalizePhone(phone)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
for _, u := range s.users {
|
||
if u.Phone == ns {
|
||
cp := u.User
|
||
cp.Role = authx.NormalizeRole(cp.Role)
|
||
return &cp, nil
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("user not found")
|
||
}
|
||
|
||
func (s *MemoryStore) PhoneExists(_ context.Context, phone string, excludeUserID int64) (bool, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
phone = strings.TrimSpace(phone)
|
||
if phone == "" {
|
||
return false, nil
|
||
}
|
||
for _, u := range s.users {
|
||
if u.Phone == phone && u.UserID != excludeUserID {
|
||
return true, nil
|
||
}
|
||
}
|
||
return false, nil
|
||
}
|
||
|
||
func (s *MemoryStore) BindPhone(_ context.Context, userID int64, phone string) (*User, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
u, ok := s.byID[userID]
|
||
if !ok {
|
||
return nil, fmt.Errorf("user not found")
|
||
}
|
||
phone = strings.TrimSpace(phone)
|
||
if phone == "" {
|
||
u.Phone = ""
|
||
u.UsernameLoginDisabled = false
|
||
cp := u.User
|
||
return &cp, nil
|
||
}
|
||
ns, err := NormalizePhone(phone)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, o := range s.users {
|
||
if o.Phone == ns && o.UserID != userID {
|
||
return nil, fmt.Errorf("该手机号已被绑定")
|
||
}
|
||
}
|
||
u.Phone = ns
|
||
cp := u.User
|
||
return &cp, nil
|
||
}
|
||
|
||
func (s *MemoryStore) SetUsernameLoginDisabled(_ context.Context, userID int64, disabled bool) (*User, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
u, ok := s.byID[userID]
|
||
if !ok {
|
||
return nil, fmt.Errorf("user not found")
|
||
}
|
||
if disabled && strings.TrimSpace(u.Phone) == "" {
|
||
return nil, fmt.Errorf("请先绑定手机号,再禁用用户名登录")
|
||
}
|
||
u.UsernameLoginDisabled = disabled
|
||
cp := u.User
|
||
return &cp, nil
|
||
}
|
||
|
||
func (s *MemoryStore) JoinTenant(_ context.Context, userID, tenantID int64, role string, orgUnitID int64) (*User, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
u, ok := s.byID[userID]
|
||
if !ok {
|
||
return nil, fmt.Errorf("user not found")
|
||
}
|
||
if u.TenantID > 0 && u.Status == StatusActive {
|
||
return nil, fmt.Errorf("user already joined a tenant")
|
||
}
|
||
if tenantID <= 0 {
|
||
return nil, fmt.Errorf("invalid tenant")
|
||
}
|
||
role = normalizePlatformRole(role)
|
||
u.TenantID = tenantID
|
||
u.OrgUnitID = orgUnitID
|
||
u.Role = role
|
||
u.Status = StatusActive
|
||
cp := u.User
|
||
return &cp, nil
|
||
}
|
||
|
||
func (s *MemoryStore) SetOrgUnit(_ context.Context, userID, tenantID, orgUnitID int64) (*User, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
u, ok := s.byID[userID]
|
||
if !ok || u.TenantID != tenantID {
|
||
return nil, fmt.Errorf("user not found")
|
||
}
|
||
u.OrgUnitID = orgUnitID
|
||
cp := u.User
|
||
return &cp, nil
|
||
}
|
||
|
||
func (s *MemoryStore) CreateTenantAsOwner(_ context.Context, userID int64, tenantName string) (*User, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
u, ok := s.byID[userID]
|
||
if !ok {
|
||
return nil, fmt.Errorf("user not found")
|
||
}
|
||
if u.TenantID > 0 && u.Status == StatusActive {
|
||
return nil, fmt.Errorf("user already joined a tenant")
|
||
}
|
||
name := strings.TrimSpace(tenantName)
|
||
if name == "" {
|
||
name = u.DisplayName
|
||
}
|
||
if name == "" {
|
||
name = u.Username
|
||
}
|
||
s.seqTen++
|
||
u.TenantID = s.seqTen
|
||
u.Role = authx.Role管理员
|
||
u.Status = StatusActive
|
||
slug := SuggestTenantSlug(name)
|
||
if slug == "" {
|
||
slug = fmt.Sprintf("t%d", s.seqTen)
|
||
}
|
||
base := slug
|
||
for i := 2; ; i++ {
|
||
clash := false
|
||
for _, t := range s.tenants {
|
||
if t.Slug == slug {
|
||
clash = true
|
||
break
|
||
}
|
||
}
|
||
if !clash {
|
||
if _, err := NormalizeTenantSlug(slug); err == nil {
|
||
break
|
||
}
|
||
}
|
||
slug = fmt.Sprintf("%s-%d", base, i)
|
||
if i > 50 {
|
||
slug = fmt.Sprintf("t%d", s.seqTen)
|
||
break
|
||
}
|
||
}
|
||
ns, _ := NormalizeTenantSlug(slug)
|
||
if ns == "" {
|
||
ns = fmt.Sprintf("t%d", s.seqTen)
|
||
}
|
||
s.tenants[s.seqTen] = &TenantInfo{TenantID: s.seqTen, Name: name, Slug: ns, CreatedAt: time.Now().UTC()}
|
||
cp := u.User
|
||
return &cp, nil
|
||
}
|
||
|
||
type PostgresStore struct {
|
||
DB *sql.DB
|
||
}
|
||
|
||
func NewPostgresStore(db *sql.DB) *PostgresStore {
|
||
return &PostgresStore{DB: db}
|
||
}
|
||
|
||
func (s *PostgresStore) Register(ctx context.Context, username, password, displayName string) (*User, error) {
|
||
if len(username) < 3 || len(password) < 6 {
|
||
return nil, fmt.Errorf("username>=3 and password>=6 required")
|
||
}
|
||
hash, err := hashPassword(password)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
name := displayName
|
||
if name == "" {
|
||
name = username
|
||
}
|
||
u := &User{TenantID: 0, Username: username, DisplayName: name, Role: RolePending, Status: StatusPending}
|
||
err = s.DB.QueryRowContext(ctx, `
|
||
INSERT INTO platform_meta.users(tenant_id, username, password_hash, display_name, role, status)
|
||
VALUES(NULL,$1,$2,$3,'pending','pending')
|
||
RETURNING user_id, created_at`, username, hash, name,
|
||
).Scan(&u.UserID, &u.CreatedAt)
|
||
if err != nil {
|
||
if isUnique(err) {
|
||
return nil, fmt.Errorf("username already exists")
|
||
}
|
||
return nil, err
|
||
}
|
||
return u, nil
|
||
}
|
||
|
||
func (s *PostgresStore) EnsureBootstrapOwner(ctx context.Context, username, password, displayName, companyName string) (*User, error) {
|
||
if u, err := s.Login(ctx, username, password); err == nil {
|
||
return u, nil
|
||
}
|
||
hash, err := hashPassword(password)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
tx, err := s.DB.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
name := displayName
|
||
if name == "" {
|
||
name = username
|
||
}
|
||
tenName := strings.TrimSpace(companyName)
|
||
if tenName == "" {
|
||
tenName = name
|
||
}
|
||
tenSlug := "demo"
|
||
if tenName != "演示公司" && tenName != "演示账号" {
|
||
tenSlug = SuggestTenantSlug(tenName)
|
||
if tenSlug == "" {
|
||
tenSlug = "demo"
|
||
}
|
||
}
|
||
var tenantID int64
|
||
if err := tx.QueryRowContext(ctx,
|
||
`INSERT INTO platform_meta.tenants(name, slug) VALUES($1,$2) RETURNING tenant_id`, tenName, tenSlug,
|
||
).Scan(&tenantID); err != nil {
|
||
return nil, err
|
||
}
|
||
u := &User{TenantID: tenantID, Username: username, DisplayName: name, Role: authx.Role管理员, Status: StatusActive}
|
||
err = tx.QueryRowContext(ctx, `
|
||
INSERT INTO platform_meta.users(tenant_id, username, password_hash, display_name, role, status)
|
||
VALUES($1,$2,$3,$4,$5,'active')
|
||
RETURNING user_id, created_at`, tenantID, username, hash, name, authx.Role管理员,
|
||
).Scan(&u.UserID, &u.CreatedAt)
|
||
if err != nil {
|
||
if isUnique(err) {
|
||
_ = tx.Rollback()
|
||
return s.Login(ctx, username, password)
|
||
}
|
||
return nil, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return nil, err
|
||
}
|
||
return u, nil
|
||
}
|
||
|
||
func (s *PostgresStore) EnsurePlatformAdmin(ctx context.Context, username, password, displayName string) (*User, error) {
|
||
hash, err := hashPassword(password)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
name := displayName
|
||
if name == "" {
|
||
name = username
|
||
}
|
||
|
||
var userID int64
|
||
err = s.DB.QueryRowContext(ctx, `SELECT user_id FROM platform_meta.users WHERE username=$1`, username).Scan(&userID)
|
||
if err == nil {
|
||
_, err = s.DB.ExecContext(ctx, `
|
||
UPDATE platform_meta.users
|
||
SET password_hash=$1, role=$2, status='active', tenant_id=NULL, org_unit_id=NULL,
|
||
display_name=CASE WHEN $3='' THEN display_name ELSE $3 END
|
||
WHERE user_id=$4`, hash, authx.Role超级管理员, name, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.GetByID(ctx, userID)
|
||
}
|
||
if !errors.Is(err, sql.ErrNoRows) {
|
||
return nil, err
|
||
}
|
||
|
||
u := &User{TenantID: 0, Username: username, DisplayName: name, Role: authx.Role超级管理员, Status: StatusActive}
|
||
err = s.DB.QueryRowContext(ctx, `
|
||
INSERT INTO platform_meta.users(tenant_id, username, password_hash, display_name, role, status)
|
||
VALUES(NULL,$1,$2,$3,$4,'active')
|
||
RETURNING user_id, created_at`, username, hash, name, authx.Role超级管理员,
|
||
).Scan(&u.UserID, &u.CreatedAt)
|
||
if err != nil {
|
||
if isUnique(err) {
|
||
// 并发创建:再走一次 upsert
|
||
return s.EnsurePlatformAdmin(ctx, username, password, displayName)
|
||
}
|
||
return nil, err
|
||
}
|
||
return u, nil
|
||
}
|
||
|
||
func (s *PostgresStore) ListTenants(ctx context.Context) ([]TenantInfo, error) {
|
||
rows, err := s.DB.QueryContext(ctx, `
|
||
SELECT t.tenant_id, t.name, COALESCE(t.slug,''), t.created_at,
|
||
(SELECT COUNT(*) FROM platform_meta.users u WHERE u.tenant_id=t.tenant_id AND COALESCE(u.status,'active')='active') AS user_count,
|
||
(SELECT COUNT(*) FROM platform_meta.tenant_apps a WHERE a.tenant_id=t.tenant_id) AS app_count
|
||
FROM platform_meta.tenants t
|
||
ORDER BY t.tenant_id`)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var out []TenantInfo
|
||
for rows.Next() {
|
||
var t TenantInfo
|
||
if err := rows.Scan(&t.TenantID, &t.Name, &t.Slug, &t.CreatedAt, &t.UserCount, &t.AppCount); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, t)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *PostgresStore) GetTenant(ctx context.Context, tenantID int64) (*TenantInfo, error) {
|
||
var t TenantInfo
|
||
err := s.DB.QueryRowContext(ctx, `
|
||
SELECT t.tenant_id, t.name, COALESCE(t.slug,''), t.created_at,
|
||
(SELECT COUNT(*) FROM platform_meta.users u WHERE u.tenant_id=t.tenant_id AND COALESCE(u.status,'active')='active'),
|
||
(SELECT COUNT(*) FROM platform_meta.tenant_apps a WHERE a.tenant_id=t.tenant_id)
|
||
FROM platform_meta.tenants t WHERE t.tenant_id=$1`, tenantID,
|
||
).Scan(&t.TenantID, &t.Name, &t.Slug, &t.CreatedAt, &t.UserCount, &t.AppCount)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return nil, fmt.Errorf("tenant not found")
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &t, nil
|
||
}
|
||
|
||
func (s *PostgresStore) GetTenantBySlug(ctx context.Context, slug string) (*TenantInfo, error) {
|
||
ns, err := NormalizeTenantSlug(slug)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var t TenantInfo
|
||
err = s.DB.QueryRowContext(ctx, `
|
||
SELECT t.tenant_id, t.name, COALESCE(t.slug,''), t.created_at,
|
||
(SELECT COUNT(*) FROM platform_meta.users u WHERE u.tenant_id=t.tenant_id AND COALESCE(u.status,'active')='active'),
|
||
(SELECT COUNT(*) FROM platform_meta.tenant_apps a WHERE a.tenant_id=t.tenant_id)
|
||
FROM platform_meta.tenants t WHERE t.slug=$1`, ns,
|
||
).Scan(&t.TenantID, &t.Name, &t.Slug, &t.CreatedAt, &t.UserCount, &t.AppCount)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return nil, fmt.Errorf("tenant not found")
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &t, nil
|
||
}
|
||
|
||
func (s *PostgresStore) CreateTenant(ctx context.Context, name, slug string) (*TenantInfo, error) {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return nil, fmt.Errorf("name required")
|
||
}
|
||
if strings.TrimSpace(slug) == "" {
|
||
slug = SuggestTenantSlug(name)
|
||
}
|
||
ns, err := NormalizeTenantSlug(slug)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var t TenantInfo
|
||
err = s.DB.QueryRowContext(ctx,
|
||
`INSERT INTO platform_meta.tenants(name, slug) VALUES($1,$2) RETURNING tenant_id, name, slug, created_at`, name, ns,
|
||
).Scan(&t.TenantID, &t.Name, &t.Slug, &t.CreatedAt)
|
||
if err != nil {
|
||
if isUnique(err) {
|
||
return nil, fmt.Errorf("slug already exists: %s", ns)
|
||
}
|
||
return nil, err
|
||
}
|
||
return &t, nil
|
||
}
|
||
|
||
func (s *PostgresStore) UpdateTenant(ctx context.Context, tenantID int64, name, slug string) (*TenantInfo, error) {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return nil, fmt.Errorf("name required")
|
||
}
|
||
if strings.TrimSpace(slug) == "" {
|
||
res, err := s.DB.ExecContext(ctx, `UPDATE platform_meta.tenants SET name=$1 WHERE tenant_id=$2`, name, tenantID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
n, _ := res.RowsAffected()
|
||
if n == 0 {
|
||
return nil, fmt.Errorf("tenant not found")
|
||
}
|
||
return s.GetTenant(ctx, tenantID)
|
||
}
|
||
ns, err := NormalizeTenantSlug(slug)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
res, err := s.DB.ExecContext(ctx, `UPDATE platform_meta.tenants SET name=$1, slug=$2 WHERE tenant_id=$3`, name, ns, tenantID)
|
||
if err != nil {
|
||
if isUnique(err) {
|
||
return nil, fmt.Errorf("slug already exists: %s", ns)
|
||
}
|
||
return nil, err
|
||
}
|
||
n, _ := res.RowsAffected()
|
||
if n == 0 {
|
||
return nil, fmt.Errorf("tenant not found")
|
||
}
|
||
return s.GetTenant(ctx, tenantID)
|
||
}
|
||
|
||
func (s *PostgresStore) EnsureTenantSlugs(ctx context.Context) error {
|
||
rows, err := s.DB.QueryContext(ctx, `SELECT tenant_id, name FROM platform_meta.tenants WHERE COALESCE(slug,'')='' ORDER BY tenant_id`)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
type row struct {
|
||
id int64
|
||
name string
|
||
}
|
||
var need []row
|
||
for rows.Next() {
|
||
var r row
|
||
if err := rows.Scan(&r.id, &r.name); err != nil {
|
||
return err
|
||
}
|
||
need = append(need, r)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return err
|
||
}
|
||
for _, r := range need {
|
||
base := SuggestTenantSlug(r.name)
|
||
if base == "" {
|
||
base = fmt.Sprintf("t%d", r.id)
|
||
}
|
||
cand := base
|
||
for i := 2; i < 100; i++ {
|
||
ns, nerr := NormalizeTenantSlug(cand)
|
||
if nerr != nil {
|
||
cand = fmt.Sprintf("t%d", r.id)
|
||
ns = cand
|
||
}
|
||
_, err := s.DB.ExecContext(ctx, `UPDATE platform_meta.tenants SET slug=$1 WHERE tenant_id=$2 AND COALESCE(slug,'')=''`, ns, r.id)
|
||
if err == nil {
|
||
break
|
||
}
|
||
if isUnique(err) {
|
||
cand = fmt.Sprintf("%s-%d", base, i)
|
||
continue
|
||
}
|
||
return err
|
||
}
|
||
}
|
||
// 演示公司固定 slug=demo(若仍空或为自动生成)
|
||
_, _ = s.DB.ExecContext(ctx, `
|
||
UPDATE platform_meta.tenants SET slug='demo'
|
||
WHERE name IN ('演示公司','演示账号') AND (COALESCE(slug,'')='' OR slug LIKE 't%' OR slug='yan-shi')
|
||
AND NOT EXISTS (SELECT 1 FROM platform_meta.tenants x WHERE x.slug='demo' AND x.name NOT IN ('演示公司','演示账号'))`)
|
||
return nil
|
||
}
|
||
|
||
func (s *PostgresStore) RenameTenantIf(ctx context.Context, oldName, newName string) error {
|
||
oldName = strings.TrimSpace(oldName)
|
||
newName = strings.TrimSpace(newName)
|
||
if oldName == "" || newName == "" || oldName == newName {
|
||
return nil
|
||
}
|
||
_, err := s.DB.ExecContext(ctx, `UPDATE platform_meta.tenants SET name=$1 WHERE name=$2`, newName, oldName)
|
||
return err
|
||
}
|
||
|
||
func (s *MemoryStore) RenameTenantIf(_ context.Context, oldName, newName string) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
oldName = strings.TrimSpace(oldName)
|
||
newName = strings.TrimSpace(newName)
|
||
if oldName == "" || newName == "" {
|
||
return nil
|
||
}
|
||
for _, t := range s.tenants {
|
||
if t.Name == oldName {
|
||
t.Name = newName
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *PostgresStore) ListMembers(ctx context.Context, tenantID int64) ([]User, error) {
|
||
rows, err := s.DB.QueryContext(ctx, `
|
||
SELECT user_id, COALESCE(tenant_id,0), COALESCE(org_unit_id,0), username, COALESCE(phone,''), COALESCE(username_login_disabled,false), display_name, role, COALESCE(status,'active'), created_at
|
||
FROM platform_meta.users
|
||
WHERE tenant_id=$1
|
||
ORDER BY user_id`, tenantID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var out []User
|
||
for rows.Next() {
|
||
var u User
|
||
if err := rows.Scan(&u.UserID, &u.TenantID, &u.OrgUnitID, &u.Username, &u.Phone, &u.UsernameLoginDisabled, &u.DisplayName, &u.Role, &u.Status, &u.CreatedAt); err != nil {
|
||
return nil, err
|
||
}
|
||
u.Role = authx.NormalizeRole(u.Role)
|
||
if authx.IsPlatformAdmin(u.Role) {
|
||
continue
|
||
}
|
||
out = append(out, u)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *PostgresStore) UsernameExists(ctx context.Context, username string) (bool, error) {
|
||
var n int
|
||
err := s.DB.QueryRowContext(ctx, `SELECT 1 FROM platform_meta.users WHERE username=$1 LIMIT 1`, strings.TrimSpace(username)).Scan(&n)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return false, nil
|
||
}
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
return true, nil
|
||
}
|
||
|
||
func (s *PostgresStore) CreateMember(ctx context.Context, tenantID int64, username, password, displayName, role string, orgUnitID int64) (*User, string, error) {
|
||
if tenantID <= 0 {
|
||
return nil, "", fmt.Errorf("tenant required")
|
||
}
|
||
if _, err := s.GetTenant(ctx, tenantID); err != nil {
|
||
return nil, "", err
|
||
}
|
||
username = strings.TrimSpace(username)
|
||
if len(username) < 3 {
|
||
return nil, "", fmt.Errorf("username>=3 required")
|
||
}
|
||
plain := strings.TrimSpace(password)
|
||
var err error
|
||
if plain == "" {
|
||
plain, err = RandomPassword(12)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
}
|
||
if len(plain) < 6 {
|
||
return nil, "", fmt.Errorf("password>=6 required")
|
||
}
|
||
if !authx.ValidPlatformRole(role) {
|
||
return nil, "", fmt.Errorf("invalid role")
|
||
}
|
||
role = authx.NormalizeRole(role)
|
||
hash, err := hashPassword(plain)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
name := strings.TrimSpace(displayName)
|
||
if name == "" {
|
||
name = username
|
||
}
|
||
u := &User{TenantID: tenantID, OrgUnitID: orgUnitID, Username: username, DisplayName: name, Role: role, Status: StatusActive}
|
||
err = s.DB.QueryRowContext(ctx, `
|
||
INSERT INTO platform_meta.users(tenant_id, org_unit_id, username, password_hash, display_name, role, status)
|
||
VALUES($1,NULLIF($2,0),$3,$4,$5,$6,'active')
|
||
RETURNING user_id, created_at`, tenantID, orgUnitID, username, hash, name, role,
|
||
).Scan(&u.UserID, &u.CreatedAt)
|
||
if err != nil {
|
||
if isUnique(err) {
|
||
return nil, "", fmt.Errorf("username already exists")
|
||
}
|
||
return nil, "", err
|
||
}
|
||
return u, plain, nil
|
||
}
|
||
|
||
func (s *PostgresStore) ChangePassword(ctx context.Context, userID int64, oldPassword, newPassword string) error {
|
||
var hash string
|
||
err := s.DB.QueryRowContext(ctx, `SELECT password_hash FROM platform_meta.users WHERE user_id=$1`, userID).Scan(&hash)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return fmt.Errorf("user not found")
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(oldPassword)) != nil {
|
||
return fmt.Errorf("旧密码不正确")
|
||
}
|
||
newPassword = strings.TrimSpace(newPassword)
|
||
if len(newPassword) < 6 {
|
||
return fmt.Errorf("password>=6 required")
|
||
}
|
||
nh, err := hashPassword(newPassword)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = s.DB.ExecContext(ctx, `UPDATE platform_meta.users SET password_hash=$1 WHERE user_id=$2`, nh, userID)
|
||
return err
|
||
}
|
||
|
||
func (s *PostgresStore) SetPassword(ctx context.Context, userID int64, password string) (string, error) {
|
||
plain := strings.TrimSpace(password)
|
||
var err error
|
||
if plain == "" {
|
||
plain, err = RandomPassword(12)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
}
|
||
if len(plain) < 6 {
|
||
return "", fmt.Errorf("password>=6 required")
|
||
}
|
||
nh, err := hashPassword(plain)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
res, err := s.DB.ExecContext(ctx, `UPDATE platform_meta.users SET password_hash=$1 WHERE user_id=$2`, nh, userID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
n, _ := res.RowsAffected()
|
||
if n == 0 {
|
||
return "", fmt.Errorf("user not found")
|
||
}
|
||
return plain, nil
|
||
}
|
||
|
||
func (s *PostgresStore) UpdateMember(ctx context.Context, tenantID, userID int64, role string, orgUnitID int64, status string) (*User, error) {
|
||
cur, err := s.GetByID(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if cur.TenantID != tenantID {
|
||
return nil, fmt.Errorf("member not found")
|
||
}
|
||
if authx.IsPlatformAdmin(cur.Role) {
|
||
return nil, fmt.Errorf("cannot edit platform admin")
|
||
}
|
||
if role != "" {
|
||
if !authx.ValidPlatformRole(role) {
|
||
return nil, fmt.Errorf("invalid role")
|
||
}
|
||
role = authx.NormalizeRole(role)
|
||
} else {
|
||
role = cur.Role
|
||
}
|
||
if status == "" {
|
||
status = cur.Status
|
||
}
|
||
if status != StatusActive && status != StatusPending && status != "disabled" {
|
||
return nil, fmt.Errorf("invalid status")
|
||
}
|
||
_, err = s.DB.ExecContext(ctx, `
|
||
UPDATE platform_meta.users SET role=$1, org_unit_id=NULLIF($2,0), status=$3
|
||
WHERE user_id=$4 AND tenant_id=$5`, role, orgUnitID, status, userID, tenantID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.GetByID(ctx, userID)
|
||
}
|
||
|
||
func (s *PostgresStore) Login(ctx context.Context, account, password string) (*User, error) {
|
||
account = strings.TrimSpace(account)
|
||
u, hash, err := s.loadAuthByUsername(ctx, account)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
if phone, nerr := NormalizePhone(account); nerr == nil {
|
||
u, hash, err = s.loadAuthByPhone(ctx, phone)
|
||
}
|
||
}
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return nil, fmt.Errorf("invalid username or password")
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
||
return nil, fmt.Errorf("invalid username or password")
|
||
}
|
||
u.Role = authx.NormalizeRole(u.Role)
|
||
if u.Status == "disabled" {
|
||
return nil, fmt.Errorf("账号已停用")
|
||
}
|
||
return u, nil
|
||
}
|
||
|
||
func (s *PostgresStore) loadAuthByUsername(ctx context.Context, username string) (*User, string, error) {
|
||
var u User
|
||
var hash string
|
||
var tenant sql.NullInt64
|
||
err := s.DB.QueryRowContext(ctx, `
|
||
SELECT user_id, tenant_id, COALESCE(org_unit_id,0), username, COALESCE(phone,''), COALESCE(username_login_disabled,false), password_hash, display_name, role, COALESCE(status,'active'), created_at
|
||
FROM platform_meta.users WHERE username=$1`, username,
|
||
).Scan(&u.UserID, &tenant, &u.OrgUnitID, &u.Username, &u.Phone, &u.UsernameLoginDisabled, &hash, &u.DisplayName, &u.Role, &u.Status, &u.CreatedAt)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
if tenant.Valid {
|
||
u.TenantID = tenant.Int64
|
||
}
|
||
return &u, hash, nil
|
||
}
|
||
|
||
func (s *PostgresStore) loadAuthByPhone(ctx context.Context, phone string) (*User, string, error) {
|
||
var u User
|
||
var hash string
|
||
var tenant sql.NullInt64
|
||
err := s.DB.QueryRowContext(ctx, `
|
||
SELECT user_id, tenant_id, COALESCE(org_unit_id,0), username, COALESCE(phone,''), COALESCE(username_login_disabled,false), password_hash, display_name, role, COALESCE(status,'active'), created_at
|
||
FROM platform_meta.users WHERE phone=$1`, phone,
|
||
).Scan(&u.UserID, &tenant, &u.OrgUnitID, &u.Username, &u.Phone, &u.UsernameLoginDisabled, &hash, &u.DisplayName, &u.Role, &u.Status, &u.CreatedAt)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
if tenant.Valid {
|
||
u.TenantID = tenant.Int64
|
||
}
|
||
return &u, hash, nil
|
||
}
|
||
|
||
func (s *PostgresStore) GetByID(ctx context.Context, userID int64) (*User, error) {
|
||
var u User
|
||
var tenant sql.NullInt64
|
||
err := s.DB.QueryRowContext(ctx, `
|
||
SELECT user_id, tenant_id, COALESCE(org_unit_id,0), username, COALESCE(phone,''), COALESCE(username_login_disabled,false), display_name, role, COALESCE(status,'active'), created_at
|
||
FROM platform_meta.users WHERE user_id=$1`, userID,
|
||
).Scan(&u.UserID, &tenant, &u.OrgUnitID, &u.Username, &u.Phone, &u.UsernameLoginDisabled, &u.DisplayName, &u.Role, &u.Status, &u.CreatedAt)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return nil, fmt.Errorf("user not found")
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if tenant.Valid {
|
||
u.TenantID = tenant.Int64
|
||
}
|
||
u.Role = authx.NormalizeRole(u.Role)
|
||
return &u, nil
|
||
}
|
||
|
||
func (s *PostgresStore) GetByPhone(ctx context.Context, phone string) (*User, error) {
|
||
ns, err := NormalizePhone(phone)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var u User
|
||
var tenant sql.NullInt64
|
||
err = s.DB.QueryRowContext(ctx, `
|
||
SELECT user_id, tenant_id, COALESCE(org_unit_id,0), username, COALESCE(phone,''), COALESCE(username_login_disabled,false), display_name, role, COALESCE(status,'active'), created_at
|
||
FROM platform_meta.users WHERE phone=$1`, ns,
|
||
).Scan(&u.UserID, &tenant, &u.OrgUnitID, &u.Username, &u.Phone, &u.UsernameLoginDisabled, &u.DisplayName, &u.Role, &u.Status, &u.CreatedAt)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return nil, fmt.Errorf("user not found")
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if tenant.Valid {
|
||
u.TenantID = tenant.Int64
|
||
}
|
||
u.Role = authx.NormalizeRole(u.Role)
|
||
return &u, nil
|
||
}
|
||
|
||
func (s *PostgresStore) PhoneExists(ctx context.Context, phone string, excludeUserID int64) (bool, error) {
|
||
phone = strings.TrimSpace(phone)
|
||
if phone == "" {
|
||
return false, nil
|
||
}
|
||
var n int
|
||
err := s.DB.QueryRowContext(ctx, `
|
||
SELECT 1 FROM platform_meta.users WHERE phone=$1 AND user_id<>$2 LIMIT 1`, phone, excludeUserID).Scan(&n)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return false, nil
|
||
}
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
return true, nil
|
||
}
|
||
|
||
func (s *PostgresStore) BindPhone(ctx context.Context, userID int64, phone string) (*User, error) {
|
||
phone = strings.TrimSpace(phone)
|
||
if phone == "" {
|
||
_, err := s.DB.ExecContext(ctx, `UPDATE platform_meta.users SET phone='', username_login_disabled=false WHERE user_id=$1`, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.GetByID(ctx, userID)
|
||
}
|
||
ns, err := NormalizePhone(phone)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
exists, err := s.PhoneExists(ctx, ns, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if exists {
|
||
return nil, fmt.Errorf("该手机号已被绑定")
|
||
}
|
||
res, err := s.DB.ExecContext(ctx, `UPDATE platform_meta.users SET phone=$1 WHERE user_id=$2`, ns, userID)
|
||
if err != nil {
|
||
if isUnique(err) {
|
||
return nil, fmt.Errorf("该手机号已被绑定")
|
||
}
|
||
return nil, err
|
||
}
|
||
n, _ := res.RowsAffected()
|
||
if n == 0 {
|
||
return nil, fmt.Errorf("user not found")
|
||
}
|
||
return s.GetByID(ctx, userID)
|
||
}
|
||
|
||
func (s *PostgresStore) SetUsernameLoginDisabled(ctx context.Context, userID int64, disabled bool) (*User, error) {
|
||
u, err := s.GetByID(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if disabled && strings.TrimSpace(u.Phone) == "" {
|
||
return nil, fmt.Errorf("请先绑定手机号,再禁用用户名登录")
|
||
}
|
||
_, err = s.DB.ExecContext(ctx, `UPDATE platform_meta.users SET username_login_disabled=$1 WHERE user_id=$2`, disabled, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.GetByID(ctx, userID)
|
||
}
|
||
|
||
func (s *PostgresStore) JoinTenant(ctx context.Context, userID, tenantID int64, role string, orgUnitID int64) (*User, error) {
|
||
if tenantID <= 0 {
|
||
return nil, fmt.Errorf("invalid tenant")
|
||
}
|
||
role = normalizePlatformRole(role)
|
||
res, err := s.DB.ExecContext(ctx, `
|
||
UPDATE platform_meta.users
|
||
SET tenant_id=$1, role=$2, status='active', org_unit_id=NULLIF($3,0)
|
||
WHERE user_id=$4 AND (tenant_id IS NULL OR status='pending' OR role='pending' OR role='待加入')`,
|
||
tenantID, role, orgUnitID, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
n, _ := res.RowsAffected()
|
||
if n == 0 {
|
||
u, gerr := s.GetByID(ctx, userID)
|
||
if gerr != nil {
|
||
return nil, fmt.Errorf("user not found")
|
||
}
|
||
if u.HasTenant() {
|
||
return nil, fmt.Errorf("user already joined a tenant")
|
||
}
|
||
return nil, fmt.Errorf("join tenant failed")
|
||
}
|
||
return s.GetByID(ctx, userID)
|
||
}
|
||
|
||
func (s *PostgresStore) SetOrgUnit(ctx context.Context, userID, tenantID, orgUnitID int64) (*User, error) {
|
||
res, err := s.DB.ExecContext(ctx, `
|
||
UPDATE platform_meta.users SET org_unit_id=NULLIF($1,0)
|
||
WHERE user_id=$2 AND tenant_id=$3`, orgUnitID, userID, tenantID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
n, _ := res.RowsAffected()
|
||
if n == 0 {
|
||
return nil, fmt.Errorf("user not found")
|
||
}
|
||
return s.GetByID(ctx, userID)
|
||
}
|
||
|
||
func (s *PostgresStore) CreateTenantAsOwner(ctx context.Context, userID int64, tenantName string) (*User, error) {
|
||
u, err := s.GetByID(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if u.HasTenant() {
|
||
return nil, fmt.Errorf("user already joined a tenant")
|
||
}
|
||
name := strings.TrimSpace(tenantName)
|
||
if name == "" {
|
||
name = u.DisplayName
|
||
}
|
||
if name == "" {
|
||
name = u.Username
|
||
}
|
||
tx, err := s.DB.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
var tenantID int64
|
||
slug := SuggestTenantSlug(name)
|
||
if slug == "" {
|
||
slug = fmt.Sprintf("co%d", time.Now().Unix()%100000)
|
||
}
|
||
ns, nerr := NormalizeTenantSlug(slug)
|
||
if nerr != nil {
|
||
ns = fmt.Sprintf("t%d", time.Now().Unix()%1000000)
|
||
}
|
||
for i := 0; i < 20; i++ {
|
||
try := ns
|
||
if i > 0 {
|
||
try = fmt.Sprintf("%s-%d", ns, i+1)
|
||
}
|
||
err = tx.QueryRowContext(ctx,
|
||
`INSERT INTO platform_meta.tenants(name, slug) VALUES($1,$2) RETURNING tenant_id`, name, try,
|
||
).Scan(&tenantID)
|
||
if err == nil {
|
||
break
|
||
}
|
||
if !isUnique(err) {
|
||
return nil, err
|
||
}
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("create tenant slug conflict")
|
||
}
|
||
res, err := tx.ExecContext(ctx, `
|
||
UPDATE platform_meta.users
|
||
SET tenant_id=$1, role='owner', status='active'
|
||
WHERE user_id=$2 AND (tenant_id IS NULL OR status='pending' OR role='pending')`,
|
||
tenantID, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
n, _ := res.RowsAffected()
|
||
if n == 0 {
|
||
return nil, fmt.Errorf("user already joined a tenant")
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return nil, err
|
||
}
|
||
return s.GetByID(ctx, userID)
|
||
}
|
||
|
||
// EnsureDemoUser 开发态确保 demo/demo123 为「演示公司」的公司管理员(不是超管自己)。
|
||
func EnsureDemoUser(ctx context.Context, store Store) {
|
||
if store == nil {
|
||
return
|
||
}
|
||
if _, err := store.Login(ctx, "demo", "demo123"); err != nil {
|
||
_, _ = store.EnsureBootstrapOwner(ctx, "demo", "demo123", "演示用户", "演示公司")
|
||
}
|
||
_ = store.RenameTenantIf(ctx, "演示账号", "演示公司")
|
||
_ = store.EnsureTenantSlugs(ctx)
|
||
ensureDevPhone(ctx, store, "demo", "13800000001")
|
||
}
|
||
|
||
// EnsurePlatformAdminUser 确保平台超级管理员 ljk_admin / ljk_admin。
|
||
func EnsurePlatformAdminUser(ctx context.Context, store Store) {
|
||
if store == nil {
|
||
return
|
||
}
|
||
if _, err := store.EnsurePlatformAdmin(ctx, "ljk_admin", "ljk_admin", "平台超级管理员"); err != nil {
|
||
fmt.Printf("ensure platform admin ljk_admin: %v\n", err)
|
||
}
|
||
ensureDevPhone(ctx, store, "ljk_admin", "13531041945")
|
||
}
|
||
|
||
func ensureDevPhone(ctx context.Context, store Store, username, phone string) {
|
||
var user *User
|
||
var err error
|
||
switch username {
|
||
case "demo":
|
||
user, err = store.Login(ctx, "demo", "demo123")
|
||
case "ljk_admin":
|
||
user, err = store.Login(ctx, "ljk_admin", "ljk_admin")
|
||
default:
|
||
return
|
||
}
|
||
if err != nil || user == nil {
|
||
return
|
||
}
|
||
want, nerr := NormalizePhone(phone)
|
||
if nerr != nil {
|
||
fmt.Printf("ensure phone for %s: %v\n", username, nerr)
|
||
return
|
||
}
|
||
cur := strings.TrimSpace(user.Phone)
|
||
if cur != want {
|
||
if _, err := store.BindPhone(ctx, user.UserID, want); err != nil {
|
||
fmt.Printf("ensure phone for %s: %v\n", username, err)
|
||
return
|
||
}
|
||
}
|
||
_, _ = store.SetUsernameLoginDisabled(ctx, user.UserID, true)
|
||
}
|
||
|
||
func hashPassword(pw string) (string, error) {
|
||
b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
||
return string(b), err
|
||
}
|
||
|
||
func isUnique(err error) bool {
|
||
return err != nil && (strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique"))
|
||
}
|
||
|
||
func normalizePlatformRole(role string) string {
|
||
n := authx.NormalizeRole(role)
|
||
if authx.ValidPlatformRole(n) {
|
||
return n
|
||
}
|
||
return authx.Role编辑
|
||
}
|