package postgres import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "strings" "time" "github.com/jackc/pgx/v5" ) // DingTalk space registration: a knowledge-base space discovered through the // login sidecar becomes a source_connections row with a DETERMINISTIC id, // derived the same way the catalog importer derives stable ids // ('sc-' + sha256('dingtalk-'+root_key)[:32]). Re-registering the same space // is an idempotent re-sync of name/root, never a duplicate row. New rows are // enabled but carry no schedule (next_refresh_at stays NULL): automatic scans // begin only after an admin sets an interval — same posture as the existing // imported DingTalk sources. // 登记失败的可行动分类(处理器据此返回不同错误码,前端才能说清"该怎么办"): // - 同名来源**仍在使用**(未归档):不能悄悄改名,也不能出现两个同名来源; // - 同一个登记键已指向另一个 root,而那个来源仍在使用。 var ( ErrDingtalkSpaceNameTaken = errors.New("dingtalk space name is already used by a live connection") ErrDingtalkSpaceIdentityConflict = errors.New("dingtalk space registration: live connection already serves another root") ) // RegisterDingtalkSpaceInput describes one space resolved by the sidecar. type RegisterDingtalkSpaceInput struct { RootKey string // sidecar inventory key, e.g. "atom" or "space-1f2e3d4c5b" Name string // live space name from DingTalk spaceProfile SpaceID string // alidocs space id from the URL RootID string // root dentry uuid Now time.Time } func (in RegisterDingtalkSpaceInput) ConnectionID() string { sum := sha256.Sum256([]byte("dingtalk-" + in.RootKey)) return "sc-" + hex.EncodeToString(sum[:])[:32] } func (in RegisterDingtalkSpaceInput) validate() error { if in.Now.IsZero() { return errors.New("dingtalk space registration: now is required") } if !validShortID(in.RootKey) || strings.TrimSpace(in.Name) == "" || len(in.Name) > 255 { return errors.New("dingtalk space registration: invalid key or name") } for _, id := range []string{in.SpaceID, in.RootID} { if id == "" || len(id) > 128 || strings.ContainsAny(id, " \t\r\n/") { return errors.New("dingtalk space registration: invalid identifier") } } return nil } func (s *Store) RegisterDingtalkSpace(ctx context.Context, in RegisterDingtalkSpaceInput) (string, error) { if err := in.validate(); err != nil { return "", err } connectionID := in.ConnectionID() var existingName, existingRoot string var existingArchived bool err := s.db.QueryRow(ctx, `SELECT name, base_url, archived_at IS NOT NULL FROM source_connections WHERE id = $1`, connectionID). Scan(&existingName, &existingRoot, &existingArchived) switch { case errors.Is(err, pgx.ErrNoRows): // fresh registration below case err != nil: return "", fmt.Errorf("load dingtalk connection: %w", err) default: // 同一个键解析到了另一个 root:**在用中**的行不能悄悄改写(可能是两个不同空间 // 撞了同一个键);已经归档的行可以就地改写 —— 这正是"去掉来源之后再用 dws // 重新获取"的语义。 if !strings.HasPrefix(existingRoot, "spaces/"+in.RootID+"?") && !existingArchived { return "", ErrDingtalkSpaceIdentityConflict } } // 名字有 UNIQUE 约束,而**旧路径登记**的来源(例如早期直接建的钉钉知识库连接, // base_url 为空、id 与 dws 现在推导出的确定性 id 不同)会一直占着这个名字。 // 归档行已经不显示在任何页面,就把它改名腾出位置(数据与审计一行不动); // 仍在使用的同名来源则直接报可行动错误,绝不悄悄改名。 var nameHolderID string var nameHolderArchived bool err = s.db.QueryRow(ctx, `SELECT id, archived_at IS NOT NULL FROM source_connections WHERE name = $1 AND id <> $2`, in.Name, connectionID). Scan(&nameHolderID, &nameHolderArchived) switch { case errors.Is(err, pgx.ErrNoRows): case err != nil: return "", fmt.Errorf("lookup dingtalk connection name: %w", err) default: if !nameHolderArchived { return "", ErrDingtalkSpaceNameTaken } if _, err := s.db.Exec(ctx, ` UPDATE source_connections SET name = $2, updated_at = $3, updated_by = $4 WHERE id = $1`, nameHolderID, archivedConnectionName(in.Name, nameHolderID), in.Now.UTC(), "dingtalk-registration"); err != nil { return "", fmt.Errorf("free archived dingtalk name: %w", err) } } // base_url stores a stable, non-secret locator (the root dentry uuid plus // the space id) so re-registration can verify identity and the UI can // explain what a DingTalk connection points at. locator := "spaces/" + in.RootID + "?space=" + in.SpaceID _, err = s.db.Exec(ctx, ` INSERT INTO source_connections ( id, source_type, name, base_url, secret_ref, enabled, schedule, last_health, created_at, updated_at, updated_by ) VALUES ($1, 'dingtalk', $2, $3, '', TRUE, '{}'::jsonb, '{}'::jsonb, $4, $4, $5) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, base_url = EXCLUDED.base_url, -- 归档过的来源被重新登记(dws 重新获取)时原地复活:清掉归档标记,不建重复行 —— -- 目录快照与历史仍属于同一行。 archived_at = NULL, archived_by = '', updated_at = EXCLUDED.updated_at, updated_by = EXCLUDED.updated_by`, connectionID, in.Name, locator, in.Now.UTC(), "dingtalk-registration") if err != nil { if isUniqueViolation(err) { return "", fmt.Errorf("dingtalk space registration: name %q already used by another connection", in.Name) } return "", fmt.Errorf("register dingtalk space: %w", err) } return connectionID, nil } // archivedConnectionName 给"已归档但名字仍占着唯一槽位"的行一个确定性新名字: // 内建短 ID 后缀保证唯一,并按 name 列(255)截断原名。 func archivedConnectionName(name, connectionID string) string { suffix := "(已下架 · " + shortConnectionIDSuffix(connectionID) + ")" runes := []rune(name) if limit := 255 - len([]rune(suffix)); len(runes) > limit && limit > 0 { runes = runes[:limit] } return string(runes) + suffix } func shortConnectionIDSuffix(connectionID string) string { trimmed := strings.TrimPrefix(connectionID, "sc-") if len(trimmed) > 8 { trimmed = trimmed[:8] } if trimmed == "" { trimmed = "unknown" } return trimmed }