Files
knowledge-governance/internal/httpapi/dingtalk_session.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

242 lines
8.1 KiB
Go

package httpapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"os"
"regexp"
"strings"
"time"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/jobs"
postgresstore "knowledge-governance/internal/platform/postgres"
)
// DingTalk login proxy. The scan-code login itself lives in the
// dingtalk-login sidecar (headless Chromium); the API is the authenticated
// gate and browser relay, so no QR or token crosses the web origin without a
// session. classifyRole keeps every non-GET at engineer+; capture replacement
// remains a deliberate human action.
//
// POST /api/v1/dingtalk-session -> start a login session {space?}
// GET /api/v1/dingtalk-session/{id} -> poll state (qr_png while waiting)
// DELETE /api/v1/dingtalk-session/{id} -> abort a session
func dingtalkSidecarURL() string {
return strings.TrimRight(strings.TrimSpace(os.Getenv("DINGTALK_LOGIN_SIDECAR_URL")), "/")
}
func requireDingtalkIdentity(w http.ResponseWriter, r *http.Request) bool {
_, ok := requireSessionIdentity(w, r)
return ok
}
func startDingtalkSession(w http.ResponseWriter, r *http.Request) {
if !requireDingtalkIdentity(w, r) {
return
}
var body struct {
Space string `json:"space"`
}
if r.Body != nil && r.ContentLength != 0 {
if err := decodeJSONStrict(r, &body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_request_body"})
return
}
}
payload, _ := json.Marshal(map[string]string{"space": strings.TrimSpace(body.Space)})
status, data := proxyDingtalk(http.MethodPost, "/sessions", payload)
log.Printf("dingtalk-login session started by %s (status %d)", actorFor(r), status)
writeProxyResult(w, status, data)
}
func getDingtalkSession(w http.ResponseWriter, r *http.Request) {
if !requireDingtalkIdentity(w, r) {
return
}
id := safeSessionID(r.PathValue("id"))
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_session_id"})
return
}
status, data := proxyDingtalk(http.MethodGet, "/sessions/"+id, nil)
writeProxyResult(w, status, data)
}
func deleteDingtalkSession(w http.ResponseWriter, r *http.Request) {
if !requireDingtalkIdentity(w, r) {
return
}
id := safeSessionID(r.PathValue("id"))
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_session_id"})
return
}
status, data := proxyDingtalk(http.MethodDelete, "/sessions/"+id, nil)
log.Printf("dingtalk-login session %s closed by %s", id, actorFor(r))
writeProxyResult(w, status, data)
}
// registerDingtalkSpace resolves a DingTalk knowledge-base space URL through
// the login sidecar and, on success, registers the returned space as a
// source connection (deterministic id, enabled, manual-only schedule until an
// admin sets an interval). Re-registering the same space re-syncs name/root.
func registerDingtalkSpace(w http.ResponseWriter, r *http.Request, config Config) {
if !requireDingtalkIdentity(w, r) {
return
}
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
var input struct {
URL string `json:"url"`
}
if err := decodeJSONStrict(r, &input); err != nil || strings.TrimSpace(input.URL) == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "url is required"})
return
}
payload, _ := json.Marshal(map[string]string{"url": strings.TrimSpace(input.URL)})
status, data := proxyDingtalk(http.MethodPost, "/spaces/add", payload)
if status != http.StatusOK {
if status == 0 {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dingtalk_login_unavailable"})
return
}
// Relay the sidecar's error (login_required / space_not_found /
// name_conflict) verbatim; bodies are machine codes, no secrets.
writeProxyResult(w, status, data)
return
}
var resolved struct {
Key string `json:"key"`
Name string `json:"name"`
Root string `json:"root"`
}
if err := json.Unmarshal(data, &resolved); err != nil || resolved.Key == "" || resolved.Name == "" || resolved.Root == "" {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "invalid sidecar registration response"})
return
}
store, ok := config.Store.(interface {
RegisterDingtalkSpace(context.Context, postgresstore.RegisterDingtalkSpaceInput) (string, error)
})
if !ok {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "registration_unavailable"})
return
}
spaceID := extractDingtalkSpaceID(input.URL)
if spaceID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid space url"})
return
}
connectionID, err := store.RegisterDingtalkSpace(r.Context(), postgresstore.RegisterDingtalkSpaceInput{
RootKey: resolved.Key, Name: resolved.Name, SpaceID: spaceID, RootID: resolved.Root, Now: config.Now(),
})
switch {
case errors.Is(err, postgresstore.ErrDingtalkSpaceNameTaken):
writeJSON(w, http.StatusConflict, map[string]string{
"error": "space_name_conflict",
"hint": "已有同名来源仍在使用(未下架):请先在该来源卡片上「去掉来源」,或确认选的是同一个知识库空间。",
})
return
case errors.Is(err, postgresstore.ErrDingtalkSpaceIdentityConflict):
writeJSON(w, http.StatusConflict, map[string]string{
"error": "space_identity_conflict",
"hint": "这个登记键已指向另一个知识库空间:请刷新列表后重试;若仍失败,把该来源「去掉来源」后再登记。",
})
return
case err != nil:
// 真正的数据库/内部故障不能伪装成"冲突":换个 500 才有人去查日志。
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "space_registration_failed"})
return
}
// First scan is enqueued right away: the new connection has no catalog
// head yet, so the generic sources/{id}/refresh endpoint would 404 until
// the first snapshot exists.
if enqueuer, ok := config.Store.(interface {
EnqueueCatalogRefreshWithOperation(context.Context, governance.CatalogSource, string, time.Time) (jobs.Job, bool, error)
}); ok {
if _, _, enqueueErr := enqueuer.EnqueueCatalogRefreshWithOperation(r.Context(), governance.CatalogSource{
ConnectionID: connectionID, SourceType: governance.SourceTypeDingTalk, Name: resolved.Name,
}, operationID+":first", config.Now().UTC()); enqueueErr != nil {
log.Printf("dingtalk space %s registered but first refresh enqueue failed: %v", connectionID, enqueueErr)
}
}
log.Printf("dingtalk space %q registered as connection %s by %s", resolved.Name, connectionID, actorFor(r))
writeJSON(w, http.StatusCreated, map[string]any{"data": map[string]string{
"connection_id": connectionID, "name": resolved.Name,
}})
}
var dingtalkSpacePattern = regexp.MustCompile(`/spaces/([A-Za-z0-9_-]+)`)
func extractDingtalkSpaceID(raw string) string {
match := dingtalkSpacePattern.FindStringSubmatch(raw)
if len(match) == 2 {
return match[1]
}
trimmed := strings.TrimSpace(raw)
if regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`).MatchString(trimmed) {
return trimmed
}
return ""
}
func safeSessionID(id string) string {
id = strings.TrimSpace(id)
if id == "" || len(id) > 64 {
return ""
}
for _, c := range id {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') {
return ""
}
}
return id
}
func writeProxyResult(w http.ResponseWriter, status int, data []byte) {
if status == 0 || data == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dingtalk_login_unavailable"})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(data)
}
func proxyDingtalk(method, path string, body []byte) (int, []byte) {
base := dingtalkSidecarURL()
if base == "" {
return 0, nil
}
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
req, err := http.NewRequest(method, base+path, reader)
if err != nil {
return 0, nil
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return 0, nil
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return resp.StatusCode, nil
}
return resp.StatusCode, data
}