Files
knowledge-governance/internal/platform/postgres/dingtalk_space.go
T
姚章浩 9a8a3e7795
build / build (push) Successful in 57s
fix(dingtalk): 登记钉钉知识库 409 space_registration_conflict —— 归档行占名 + 错误被吞
现象:点「公司级技术支持知识库」登记,返回 HTTP 409:space_registration_conflict。

根因(真数据取证):
- source_connections.name 有唯一约束。这 3 个钉钉来源是**早期路径**登记的
  (base_url 为空,id 与 dws 现在推导的确定性 id 不同):
  sc-18d10ca0… 公司级技术支持知识库 / sc-eaef0ec1… ATOM知识库 / sc-dc7cea1c… 技术诊断部知识沉淀。
- 上一步「去掉来源」只是归档下架,**名字仍占着唯一槽位** ⇒ 重新登记时 INSERT 撞
  UNIQUE(name)。
- 处理器把**所有**存储层错误都翻译成同一个 409 space_registration_conflict(真实原因被吞)。

修复:
- store:同名的另一行若**已归档** ⇒ 改名腾位(`…(已下架 · <短ID>)`,归档行不显示,
  数据与审计一行不动);若**仍在使用** ⇒ ErrDingtalkSpaceNameTaken(绝不悄悄改名)。
  同一个登记键解析到别的 root 时:在用中的行报 ErrDingtalkSpaceIdentityConflict;
  归档过的行允许就地改写("去掉后再用 dws 重新获取"的语义)。
- httpapi:分类映射 —— space_name_conflict / space_identity_conflict(409 + 可行动 hint),
  其余内部故障改回 500 space_registration_failed,不再伪装成冲突。
- web:读一次响应体同时取 error 与 hint,**hint 优先**;补两个新码的中文说明。

验证:真 postgres 集成测试复刻线上形态(旧行占名 + 归档 ⇒ 登记成功、旧行改名保持归档、
内容不丢;同名在用 ⇒ 拒绝;同键换 root 在用 ⇒ 拒绝、归档 ⇒ 就地改写);handler 分类测试
(409/409/500,且不回显内部细节);前端 hint 优先用例;make verify 15/15。
2026-10-14 12:59:52 +08:00

151 lines
6.3 KiB
Go
Raw 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
}
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
}