@@ -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": [
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user