Ship ticket-exchange and bind/policy for Z13, keep trial binds SMS-free, allow shared company bindings, and align SyncPage plus sync docs. Co-authored-by: Cursor <cursoragent@cursor.com>
233 lines
6.0 KiB
Go
233 lines
6.0 KiB
Go
package dbsync
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// Binding 本机库 ↔ 线上库映射(登记/查询;用户自助 push 时按 user_id+online_db_id 鉴权)。
|
||
type Binding struct {
|
||
ID string `json:"id"`
|
||
TenantID int64 `json:"tenant_id"`
|
||
UserID int64 `json:"user_id,omitempty"`
|
||
LocalDatabaseID string `json:"local_database_id"`
|
||
OnlineDBID string `json:"online_db_id"`
|
||
ChannelID string `json:"channel_id,omitempty"`
|
||
// Shared:公司共享库(Z12c-2);同租户任意成员可 push/pull 该 online_db_id
|
||
Shared bool `json:"shared,omitempty"`
|
||
// 可读名(与宇恒 database_name / display_name 对齐;控制台优先展示)
|
||
DatabaseName string `json:"database_name,omitempty"` // 本地库可读名
|
||
DisplayName string `json:"display_name,omitempty"` // 线上库可读名
|
||
Note string `json:"note,omitempty"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
func (s *FileStore) bindingPath() string {
|
||
return filepath.Join(s.dir, "bindings.json")
|
||
}
|
||
|
||
func (s *FileStore) EnsureBinding(b Binding) (Binding, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
list, err := s.readBindingsUnlocked()
|
||
if err != nil {
|
||
return b, err
|
||
}
|
||
b.LocalDatabaseID = strings.TrimSpace(b.LocalDatabaseID)
|
||
b.OnlineDBID = strings.TrimSpace(b.OnlineDBID)
|
||
b.DatabaseName = strings.TrimSpace(b.DatabaseName)
|
||
b.DisplayName = strings.TrimSpace(b.DisplayName)
|
||
if b.TenantID <= 0 {
|
||
return b, fmt.Errorf("tenant_id required")
|
||
}
|
||
if b.LocalDatabaseID == "" || b.OnlineDBID == "" {
|
||
return b, fmt.Errorf("local_database_id and online_db_id required")
|
||
}
|
||
now := time.Now().UTC()
|
||
for i := range list {
|
||
if list[i].TenantID == b.TenantID && list[i].LocalDatabaseID == b.LocalDatabaseID {
|
||
if b.UserID > 0 && list[i].UserID > 0 && list[i].UserID != b.UserID {
|
||
continue
|
||
}
|
||
list[i].OnlineDBID = b.OnlineDBID
|
||
if b.ChannelID != "" {
|
||
list[i].ChannelID = b.ChannelID
|
||
}
|
||
if b.DatabaseName != "" {
|
||
list[i].DatabaseName = b.DatabaseName
|
||
}
|
||
if b.DisplayName != "" {
|
||
list[i].DisplayName = b.DisplayName
|
||
}
|
||
if b.Note != "" {
|
||
list[i].Note = b.Note
|
||
}
|
||
if b.UserID > 0 {
|
||
list[i].UserID = b.UserID
|
||
}
|
||
list[i].Shared = b.Shared
|
||
list[i].UpdatedAt = now
|
||
if err := s.writeBindingsUnlocked(list); err != nil {
|
||
return b, err
|
||
}
|
||
return list[i], nil
|
||
}
|
||
}
|
||
if b.ID == "" {
|
||
b.ID = uuid.NewString()
|
||
}
|
||
b.CreatedAt = now
|
||
b.UpdatedAt = now
|
||
list = append(list, b)
|
||
if err := s.writeBindingsUnlocked(list); err != nil {
|
||
return b, err
|
||
}
|
||
return b, nil
|
||
}
|
||
|
||
func (s *FileStore) ListBindings(tenantID int64, localDatabaseID string) ([]Binding, error) {
|
||
return s.ListBindingsFiltered(tenantID, 0, localDatabaseID)
|
||
}
|
||
|
||
// ListBindingsFiltered 按租户列出;userID>0 时仅返回该用户的 Binding。
|
||
func (s *FileStore) ListBindingsFiltered(tenantID, userID int64, localDatabaseID string) ([]Binding, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
list, err := s.readBindingsUnlocked()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]Binding, 0)
|
||
for _, b := range list {
|
||
if b.TenantID != tenantID {
|
||
continue
|
||
}
|
||
if userID > 0 && b.UserID != userID {
|
||
continue
|
||
}
|
||
if localDatabaseID != "" && b.LocalDatabaseID != localDatabaseID {
|
||
continue
|
||
}
|
||
out = append(out, b)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// UserOwnsOnlineDB 用户是否登记了该 online_db_id(可选限定 channel);或该库为公司 Shared。
|
||
func (s *FileStore) UserOwnsOnlineDB(tenantID, userID int64, channelID, onlineDBID string) bool {
|
||
if tenantID <= 0 || userID <= 0 || strings.TrimSpace(onlineDBID) == "" {
|
||
return false
|
||
}
|
||
online := strings.TrimSpace(onlineDBID)
|
||
ch := strings.TrimSpace(channelID)
|
||
own, err := s.ListBindingsFiltered(tenantID, userID, "")
|
||
if err == nil {
|
||
for _, b := range own {
|
||
if strings.TrimSpace(b.OnlineDBID) != online {
|
||
continue
|
||
}
|
||
if ch != "" && b.ChannelID != "" && b.ChannelID != ch {
|
||
continue
|
||
}
|
||
return true
|
||
}
|
||
}
|
||
all, err := s.ListBindingsFiltered(tenantID, 0, "")
|
||
if err != nil {
|
||
return false
|
||
}
|
||
for _, b := range all {
|
||
if !b.Shared {
|
||
continue
|
||
}
|
||
if strings.TrimSpace(b.OnlineDBID) != online {
|
||
continue
|
||
}
|
||
if ch != "" && b.ChannelID != "" && b.ChannelID != ch {
|
||
continue
|
||
}
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// UserCanAccessChannel 用户是否登记了指向该通道的 Binding(channel_id 空视为未限定通道);含 Shared。
|
||
func (s *FileStore) UserCanAccessChannel(tenantID, userID int64, channelID string) bool {
|
||
if tenantID <= 0 || userID <= 0 || strings.TrimSpace(channelID) == "" {
|
||
return false
|
||
}
|
||
ch := strings.TrimSpace(channelID)
|
||
list, err := s.ListBindingsFiltered(tenantID, userID, "")
|
||
if err == nil {
|
||
for _, b := range list {
|
||
if b.ChannelID == "" || b.ChannelID == ch {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
all, err := s.ListBindingsFiltered(tenantID, 0, "")
|
||
if err != nil {
|
||
return false
|
||
}
|
||
for _, b := range all {
|
||
if !b.Shared {
|
||
continue
|
||
}
|
||
if b.ChannelID == "" || b.ChannelID == ch {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (s *FileStore) GetBinding(tenantID int64, localDatabaseID string) (*Binding, error) {
|
||
list, err := s.ListBindings(tenantID, localDatabaseID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(list) == 0 {
|
||
return nil, fmt.Errorf("binding not found")
|
||
}
|
||
cp := list[0]
|
||
return &cp, nil
|
||
}
|
||
|
||
func (s *FileStore) readBindingsUnlocked() ([]Binding, error) {
|
||
path := s.bindingPath()
|
||
b, err := os.ReadFile(path)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return []Binding{}, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
if len(b) == 0 {
|
||
return []Binding{}, nil
|
||
}
|
||
var list []Binding
|
||
if err := json.Unmarshal(b, &list); err != nil {
|
||
return nil, err
|
||
}
|
||
return list, nil
|
||
}
|
||
|
||
func (s *FileStore) writeBindingsUnlocked(list []Binding) error {
|
||
path := s.bindingPath()
|
||
raw, err := json.MarshalIndent(list, "", " ")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tmp := path + ".tmp"
|
||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||
return err
|
||
}
|
||
return os.Rename(tmp, path)
|
||
}
|