Files
knowledge-governance/web/src/dingtalkSession.ts
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
5.7 KiB
TypeScript
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.
import { newOperationID } from './operationKey'
// Client for the admin-only DingTalk scan-code session (系统设置 → 数据源配置).
// The API proxies to the dingtalk-login sidecar; this module never sees the
// token itself — only the base64 QR image and a terminal state.
export type DingtalkSessionState =
'starting' | 'qr' | 'scanned' | 'reusing' | 'success' | 'expired' | 'failed' | 'closed'
export interface DingtalkSessionView {
id: string
state: DingtalkSessionState
created_at: number
expires_at: number
qr_png?: string
error?: string
}
const sessionStates = new Set<string>([
'starting',
'qr',
'scanned',
'reusing',
'success',
'expired',
'failed',
'closed',
])
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export async function startDingtalkSession(space?: string): Promise<DingtalkSessionView> {
const response = await fetch('/api/v1/dingtalk-session', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Governance-Action': 'dingtalk-session',
'Idempotency-Key': newOperationID(),
},
body: JSON.stringify(space ? { space } : {}),
})
if (!response.ok) throw new Error(`发起钉钉扫码会话失败(HTTP ${response.status})`)
return parseSession(await response.json())
}
export async function fetchDingtalkSession(id: string, signal?: AbortSignal): Promise<DingtalkSessionView> {
const response = await fetch(`/api/v1/dingtalk-session/${encodeURIComponent(id)}`, {
headers: { Accept: 'application/json' },
signal,
})
if (!response.ok) throw new Error(`读取钉钉扫码会话失败(HTTP ${response.status})`)
return parseSession(await response.json())
}
export async function cancelDingtalkSession(id: string): Promise<void> {
await fetch(`/api/v1/dingtalk-session/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
}).catch(() => undefined)
}
export interface RegisteredDingtalkSpace {
connection_id: string
name: string
}
export class DingtalkRequestError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly status: number
) {
super(message)
this.name = 'DingtalkRequestError'
}
}
// Register a DingTalk knowledge-base space (URL or bare space id) as a new
// source connection. Errors carry the sidecar's machine codes so the UI can
// explain them: login_required, space_not_found, name_conflict.
export async function registerDingtalkSpace(url: string): Promise<RegisteredDingtalkSpace> {
const response = await fetch('/api/v1/dingtalk-spaces', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Governance-Action': 'dingtalk-space',
'Idempotency-Key': newOperationID(),
},
body: JSON.stringify({ url }),
})
if (!response.ok) {
// 响应体只能读一次:错误码与后端 hint 一起取出(hint 优先,它比本地文案更具体)。
let code = ''
let hint = ''
try {
const parsed = (await response.json()) as unknown
if (isRecord(parsed)) {
if (typeof parsed.error === 'string') code = parsed.error
if (typeof parsed.hint === 'string') hint = parsed.hint
}
} catch {
/* status-only */
}
const friendly: Record<string, string> = {
login_required: '钉钉扫码会话无效或已过期,请先在钉钉来源卡片上「扫码刷新会话」',
space_not_found: '未找到该知识库空间:请确认空间链接正确且当前账号有阅读权限',
name_conflict: '该空间与已登记的来源同名',
// 登记冲突分两种:同名来源还在用 vs 同一个登记键指向了别的空间。原因要说清,
// 否则用户只能看到一个无细节的 409(2026-10-14 线上就是这样卡住的)。
space_name_conflict: '已有同名来源仍在使用(未下架):先在该来源卡片上「去掉来源」,或确认选的是同一个知识库空间',
space_identity_conflict: '这个登记键已指向另一个知识库空间:刷新列表后重试;仍失败就把该来源「去掉来源」再登记',
space_registration_failed: '登记失败(服务端内部错误):已记录,请稍后重试或让管理员看服务日志',
invalid_space_url: '链接不是有效的钉钉知识库空间地址(应形如 alidocs.dingtalk.com/i/spaces/…)',
}
throw new DingtalkRequestError(
hint || friendly[code] || `登记钉钉知识库失败(HTTP ${response.status}${code ? ':' + code : ''})`,
code,
response.status
)
}
const value = (await response.json()) as unknown
if (!isRecord(value) || !isRecord(value.data)) throw new Error('invalid dingtalk space response')
const data = value.data
if (typeof data.connection_id !== 'string' || typeof data.name !== 'string')
throw new Error('invalid dingtalk space response')
return { connection_id: data.connection_id, name: data.name }
}
function parseSession(value: unknown): DingtalkSessionView {
if (
!isRecord(value) ||
typeof value.id !== 'string' ||
typeof value.state !== 'string' ||
!sessionStates.has(value.state)
) {
throw new Error('invalid dingtalk session response')
}
return {
id: value.id,
state: value.state as DingtalkSessionState,
created_at: typeof value.created_at === 'number' ? value.created_at : 0,
expires_at: typeof value.expires_at === 'number' ? value.expires_at : 0,
qr_png: typeof value.qr_png === 'string' ? value.qr_png : undefined,
error: typeof value.error === 'string' ? value.error : undefined,
}
}