Files
knowledge-governance/internal/platform/postgres/dingtalk_space.go
姚章浩 7448d130ec
build / build (push) Successful in 1m13s
feat(settings): 汇总全部数据源配置操作审计
2026-10-27 10:42:30 +08:00

155 lines
6.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}