9a8a3e7795
build / build (push) Successful in 57s
现象:点「公司级技术支持知识库」登记,返回 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。
151 lines
5.7 KiB
TypeScript
151 lines
5.7 KiB
TypeScript
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,
|
||
}
|
||
}
|