155 lines
6.3 KiB
Go
155 lines
6.3 KiB
Go
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
|
||
Actor string
|
||
}
|
||
|
||
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 strings.TrimSpace(in.Actor) == "" {
|
||
in.Actor = "dingtalk-registration"
|
||
}
|
||
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(), in.Actor); 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(), in.Actor)
|
||
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
|
||
}
|