fix(settings): 仅展示只读DWS凭证状态
build / build (push) Successful in 1m10s

This commit is contained in:
姚章浩
2026-10-27 09:56:19 +08:00
parent 5b6e154459
commit f0b12412a1
8 changed files with 149 additions and 32 deletions
+17
View File
@@ -4278,6 +4278,23 @@
}
}
},
"/api/v1/dingtalk-spaces/credential-status": {
"get": {
"tags": ["Source connections"],
"operationId": "readDWSCredentialStatus",
"summary": "只读查询 DWS 本地凭证元数据(管理员)",
"description": "仅执行 auth status --readonly,不列举目录、不兑换授权、不返回凭据值。状态不代表业务接口可用性。",
"responses": {
"200": {"$ref": "#/components/responses/Envelope"},
"401": {"$ref": "#/components/responses/Unauthorized"},
"403": {"$ref": "#/components/responses/Forbidden"},
"501": {"description": "状态读取器未接线"},
"400": {"$ref": "#/components/responses/BadRequest"},
"500": {"$ref": "#/components/responses/InternalError"},
"503": {"$ref": "#/components/responses/ServiceUnavailable"}
}
}
},
"/api/v1/dingtalk-spaces/available": {
"get": {
"tags": [
+4
View File
@@ -33,6 +33,10 @@ import (
// (见 compose.yaml 的 /data/dws 与文件型 keychain 挂载)——凭据只有一份。
type dingtalkSourceLister struct{}
func (dingtalkSourceLister) ReadDWSMetadata(ctx context.Context) (dingtalkdiscovery.CredentialStatus, error) {
return dingtalkdiscovery.ReadCredentialStatus(ctx, dingtalkdiscovery.ExecRunner{})
}
func (dingtalkSourceLister) ListAvailableDingtalkSources(ctx context.Context) (dingtalkdiscovery.Result, error) {
return dingtalkdiscovery.Discover(ctx, dingtalkdiscovery.ExecRunner{})
}
+32
View File
@@ -0,0 +1,32 @@
package dingtalkdiscovery
import (
"context"
"encoding/json"
"errors"
)
// CredentialStatus contains metadata only, never credential values.
type CredentialStatus struct {
Authenticated bool `json:"authenticated"`
RefreshTokenValid bool `json:"refresh_token_valid"`
ExpiresAt string `json:"expires_at,omitempty"`
RefreshExpiresAt string `json:"refresh_expires_at,omitempty"`
}
func ReadCredentialStatus(ctx context.Context, runner Runner) (CredentialStatus, error) {
raw, err := runner.RunDWS(ctx, "auth", "status", "--readonly", "--format", "json")
if err != nil {
return CredentialStatus{}, errors.New("could not read DWS credential status")
}
var decoded struct {
Authenticated *bool `json:"authenticated"`
RefreshTokenValid bool `json:"refresh_token_valid"`
ExpiresAt string `json:"expires_at"`
RefreshExpiresAt string `json:"refresh_expires_at"`
}
if json.Unmarshal(raw, &decoded) != nil || decoded.Authenticated == nil {
return CredentialStatus{}, errors.New("invalid DWS credential status")
}
return CredentialStatus{*decoded.Authenticated, decoded.RefreshTokenValid, decoded.ExpiresAt, decoded.RefreshExpiresAt}, nil
}
@@ -0,0 +1,37 @@
package dingtalkdiscovery
import (
"context"
"encoding/json"
"strings"
"testing"
)
func TestCredentialStatusReadOnlyMetadata(t *testing.T) {
key := "auth status --readonly --format json"
runner := &fakeRunner{responses: map[string]string{key: `{"authenticated":true,"refresh_token_valid":true,"expires_at":"2026-10-26T20:21:10+08:00","refresh_expires_at":"2026-11-25T18:21:10+08:00","access_token":"not-a-real-secret"}`}}
result, err := ReadCredentialStatus(context.Background(), runner)
if err != nil {
t.Fatal(err)
}
if !result.Authenticated || !result.RefreshTokenValid || len(runner.seen) != 1 || runner.seen[0] != key {
t.Fatalf("unexpected status or command: %+v %+v", result, runner.seen)
}
raw, _ := json.Marshal(result)
if strings.Contains(string(raw), "not-a-real-secret") || strings.Contains(string(raw), "access_token") {
t.Fatal("credential leaked")
}
}
func TestCredentialStatusRejectsUnknownOutput(t *testing.T) {
_, err := ReadCredentialStatus(context.Background(), &fakeRunner{})
if err == nil {
t.Fatal("missing authentication field accepted")
}
}
func TestCredentialStatusLoggedOut(t *testing.T) {
runner := &fakeRunner{responses: map[string]string{"auth status --readonly --format json": `{"authenticated":false}`}}
got, err := ReadCredentialStatus(context.Background(), runner)
if err != nil || got.Authenticated {
t.Fatalf("%+v %v", got, err)
}
}
+32
View File
@@ -0,0 +1,32 @@
package httpapi
import (
"context"
"knowledge-governance/internal/dingtalkdiscovery"
"net/http"
"time"
)
type dwsMetadataReader interface {
ReadDWSMetadata(context.Context) (dingtalkdiscovery.CredentialStatus, error)
}
func readDWSCredentials(w http.ResponseWriter, r *http.Request, config Config) {
if _, ok := requireSessionIdentity(w, r); !ok {
return
}
reader, ok := config.DingtalkSources.(dwsMetadataReader)
if !ok {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "DWS credential status not configured"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
result, err := reader.ReadDWSMetadata(ctx)
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "无法读取 DWS 凭证状态"})
return
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]any{"data": result})
}
+1
View File
@@ -434,6 +434,7 @@ func NewHandler(config Config) (http.Handler, error) {
mux.HandleFunc("POST /api/v1/source-catalog/validate", func(w http.ResponseWriter, r *http.Request) {
validateSourceSelection(w, r, config)
})
mux.HandleFunc("GET /api/v1/dingtalk-spaces/credential-status", func(w http.ResponseWriter, r *http.Request) { readDWSCredentials(w, r, config) })
mux.HandleFunc("GET /api/v1/dingtalk-spaces/available", func(w http.ResponseWriter, r *http.Request) {
listDingtalkSources(w, r, config)
})
+25 -31
View File
@@ -1,7 +1,5 @@
import './SourceManagement.css'
import { useEffect, useState } from 'react'
import { DingtalkSessionControl } from './DingtalkSessionControl'
import { fetchAvailableDingtalkSources } from './dingtalkSources'
import { archiveSourceConnection, fetchSourceConnectionList, fetchSourceSettingsRevisions } from './sourceConnections'
import { useOptionalAuth } from './auth'
import type { SourceConnectionListItem, SourceSettingsRevision } from './sourceConnections'
@@ -22,42 +20,38 @@ const choices = [
const errorText = (error: unknown) => (error instanceof Error ? error.message : '操作失败,请重试')
export function DingtalkAccountStatus() {
const [status, setStatus] = useState('检查中…')
const [error, setError] = useState('')
const [revision, setRevision] = useState(0)
const [expanded, setExpanded] = useState(false)
const [status, setStatus] = useState('读取中…')
const [expires, setExpires] = useState('')
useEffect(() => {
const controller = new AbortController()
setStatus('检查中…')
setError('')
void fetchAvailableDingtalkSources(controller.signal)
.then((data) => {
if (!controller.signal.aborted)
setStatus(data.knowledge_notice || data.drive_notice ? '已连接,部分目录受限' : '已连接')
void fetch('/api/v1/dingtalk-spaces/credential-status', {
headers: { Accept: 'application/json' },
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) throw new Error('读取失败')
const value = await response.json()
const data = value.data
if (!data || typeof data.authenticated !== 'boolean' || typeof data.refresh_token_valid !== 'boolean')
throw new Error('状态格式错误')
if (controller.signal.aborted) return
setStatus(!data.authenticated ? '未登录' : data.refresh_token_valid ? '已登录' : '凭证需更新')
setExpires(data.authenticated && typeof data.refresh_expires_at === 'string' ? data.refresh_expires_at : '')
})
.catch((cause) => {
if (!controller.signal.aborted) {
setStatus('状态暂不可用')
setError(errorText(cause))
}
.catch(() => {
if (!controller.signal.aborted) setStatus('状态读取失败')
})
return () => controller.abort()
}, [revision])
}, [])
return (
<div className="source-account">
<button className="link" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>
钉钉账号 · {status}
</button>
{expanded ? (
<div className="source-account-detail">
<p>此账号授权由所有钉钉来源共享。</p>
{error ? <p role="alert">{error}</p> : null}
<DingtalkSessionControl triggerLabel="重新授权" onSuccess={() => setRevision((value) => value + 1)} />
<button className="link" onClick={() => setRevision((value) => value + 1)}>
重新检查
</button>
</div>
<div className="source-account" role="status">
<span>DWS 凭证 · {status}</span>
{expires ? (
<small> · 续期凭证有效期至 {new Date(expires).toLocaleString('zh-CN', { hour12: false })}</small>
) : null}
<div>
<small>仅显示本地凭证状态,不检测目录权限或业务接口。</small>
</div>
</div>
)
}
+1 -1
View File
@@ -567,7 +567,7 @@ describe('SystemSettingsView 数据源配置的默认清单', () => {
expect(html).toContain('aria-expanded="false"')
expect(html).toContain('仅手动刷新')
expect(html).toContain('数据源设置审计')
expect((html.match(/钉钉账号 ·/g) ?? []).length).toBe(1)
expect((html.match(/DWS 凭证 ·/g) ?? []).length).toBe(1)
for (const removed of ['+ 新增钉钉知识库', '文件格式筛选']) expect(html).not.toContain(removed)
// 文档工程师看不到添加向导与「去掉来源」:两者都是管理员动作。
const engineer = view('engineer')