Compare commits

...

12 Commits

45 changed files with 1502 additions and 2768 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.env
.env.*
!.env.example
bin/
.acceptance-canary/
.hermes/
__pycache__/
*.pyc
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
FROM golang:1.26.5-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
ARG GOPROXY=https://goproxy.cn,direct
RUN GOPROXY=$GOPROXY go mod download
COPY . ./
ARG VERSION=dev
ARG COMMIT_SHA=unknown
ARG BUILD_TIME=unknown
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION} -X main.commitSHA=${COMMIT_SHA} -X main.buildTime=${BUILD_TIME}" \
-o /out/dobotai ./cmd/dobotai
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /out/dobotai /usr/local/bin/dobotai
USER 10001:10001
ENTRYPOINT ["/usr/local/bin/dobotai"]
+92
View File
@@ -37,6 +37,78 @@ DobotAI 是 WeKnora 的企业外部渠道集成层,负责钉钉协议、身份
- **DobotAI PostgreSQL**:只保存渠道与业务编排数据,不保存 WeKnora 知识正文。
- **Redis**:仅用于队列、重试、幂等租约和短期状态。
## 目录结构
```text
.
├── cmd/
│ ├── dobotai/ 主程序入口、运行时装配与后台 Worker
│ └── directory-import/ 钉钉通讯录导入命令
├── internal/
│ ├── channels/dingtalk/ 钉钉 Stream、卡片、媒体、反馈与群通知
│ ├── qa/ 问答主流程、WeKnora 调用和检索信号记录
│ ├── weknora/ SSE、工具结果和引用解析
│ ├── coverage/ 覆盖判定、Support Case 和人工提醒
│ ├── reasoningmode/ /think 推理模式
│ ├── settings/ /setting 与首位管理员绑定
│ ├── outboundimage/ resource:// 图片私有 OSS 外发
│ ├── questiontable/ 问题表工作流
│ ├── faqtable/ FAQ 表工作流
│ ├── directory/ 钉钉通讯录查询与导入
│ ├── audit/ 审计查询
│ ├── httpapi/ /healthz、/version、/v1/qa 与审计 API
│ ├── core/ 核心领域类型
│ ├── session/ 会话键和会话标题
│ ├── agentbinding/ 渠道到 WeKnora Agent 的绑定
│ ├── feedback/ 点赞/点踩领域逻辑
│ ├── secretstore/ 本地密钥引用存储
│ ├── dingtalkconfig/ 钉钉凭据校验
│ ├── runtime/ 运行时管理
│ └── store/postgres/ PostgreSQL 持久化实现
├── db/migrations/ SQL 迁移及其契约测试
├── deploy/dobotai-dingtalk/ 容器 Compose、安装/配置/启动/绑定脚本和部署手册
├── docs/ 架构说明与 API 边界文档
├── scripts/ 本地部署与部署资产契约测试
├── Dockerfile DobotAI 容器镜像构建
├── .env.example 无真实凭据的配置示例
└── README.md 项目功能、边界和开发说明
```
目录职责规则:`cmd/`只装配进程,`internal/`承载业务模块,`internal/store/postgres/`只实现数据库持久化,`db/migrations/`只放Schema变更,`deploy/`只放容器运行资产。历史计划、备份、镜像归档和真实环境文件均不进入Git。
## 部署方式
| 方式 | 适用场景 | 入口 | 数据库与迁移 | 推荐程度 |
| --- | --- | --- | --- | --- |
| 源码/原生二进制 | 本地开发、快速调试、临时问题定位 | `go run ./cmd/dobotai` 或受控构建的二进制 | 操作人员准备独立PostgreSQL并显式执行迁移 | 仅开发 |
| Docker Compose | 测试、预发布、生产候选 | 仓库根目录`install-docker.sh` | 脚本构建镜像、创建`dobotai-postgres`和`dobotai-postgres-data`,并按序执行迁移 | 推荐 |
### 源码/原生部署
```bash
# 开发机:使用Git外的环境文件注入真实配置
go run ./cmd/dobotai
curl http://127.0.0.1:8090/healthz
```
源码方式不自动创建数据库、不管理密钥目录、不自动执行SQL迁移,也不适合作为长期测试或生产运行方式。
### Docker Compose 部署
```bash
# 克隆后进入仓库根目录执行:自动安装Docker/Compose(如缺失)、构建镜像、
# 准备目录、独立PostgreSQL、命名卷、env模板和迁移。
sudo bash ./install-docker.sh
# 配置WeKnora和钉钉后自动启动应用
sudo dobotai-dingtalk-configure
# 仅首次且没有有效管理员时执行
sudo dobotai-dingtalk-bind-admin
```
完整的安装、现有数据库迁移、备份、回滚和验收说明见 [Docker 部署手册](deploy/dobotai-dingtalk/README.md)。
## 钉钉 Stream
使用官方 `dingtalk-stream-sdk-go`,不暴露公网回调入口。
@@ -65,10 +137,29 @@ DOBOTAI_TENANT_KEY
持续推理默认最多10轮,成功回答才消耗轮次;10分钟无交互后逻辑到期,下一次问题自动回到快速问答。状态按 `tenant + channel + external_actor_id` 隔离。
## 首位管理员终端绑定
首位管理员不通过网页生成验证码。先确保钉钉 Stream 机器人已经正常运行,登录部署服务器后执行:
```bash
dobotai bootstrap-admin
```
若二进制未加入 `PATH`,使用其实际绝对路径执行。该命令只读取 `DATABASE_URL` 和 `DOBOTAI_TENANT_KEY`,不会启动 WeKnora、钉钉消费者或问答服务。
命令会在终端使用加密随机数生成一个有效期10分钟的六码绑定码,并保持等待。候选管理员必须私聊机器人发送:
```text
/setting <六码绑定码>
```
验证码正确后,终端自动将该钉钉用户绑定为 `super_admin` 并显示成功。已有有效管理员时命令会拒绝执行,防止通过该入口追加或替换管理员。DobotAI 不提供网页管理面;钉钉凭据和运行配置由部署环境文件管理。
## 覆盖与人工支持
- 有结构化检索引用的回答标记为已覆盖;
- 没有引用但已执行检索的回答标记为未覆盖;
- 没有引用且未执行检索的文本标记为 `covered_non_knowledge_response`,不创建 Support Case;
- 纯图片视觉识别单独记录为 `covered_visual_recognition`,不计入“未覆盖”;
- 无权威知识时,产品统一回复:`当前暂无已发布的权威知识,暂不建议操作。`;
- 人工通知只带当前问题和同一用户此前最多5个问题,不包含回答、引用或置信度;
@@ -110,6 +201,7 @@ DobotAI 使用独立 PostgreSQL,禁止指向 WeKnora 数据库。生产部署
000001_core ... 000018_coverage_visual_recognition_reason
000019_reasoning_mode_state
000020_feedback_support_trigger
000021_coverage_non_knowledge_response_reason
```
迁移前应执行逻辑备份并验证恢复;迁移、二进制部署和钉钉渠道切换是独立步骤,不能把健康检查代替真实渠道验收。
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"context"
cryptorand "crypto/rand"
"errors"
"fmt"
"io"
"math/big"
"os"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
)
const bootstrapAdminTTL = 10 * time.Minute
type bootstrapAdminRepository interface {
AdministratorCount(context.Context, string) (int, error)
CreateBootstrapChallenge(context.Context, string, string, time.Time) error
ConfirmBootstrap(context.Context, string) error
}
var createBootstrapChallenge = func(ctx context.Context, repo bootstrapAdminRepository, tenant, code string, expiry time.Time) error {
return repo.CreateBootstrapChallenge(ctx, tenant, code, expiry)
}
func runBootstrapAdminCommand(ctx context.Context, output io.Writer) error {
tenant := strings.TrimSpace(os.Getenv("DOBOTAI_TENANT_KEY"))
store, err := postgres.Open(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
return err
}
defer store.Close()
repository, err := postgres.NewSettingsRepository(store)
if err != nil {
return err
}
return runBootstrapAdmin(ctx, output, repository, tenant, cryptorand.Reader, time.Now, time.Second)
}
func runBootstrapAdmin(ctx context.Context, output io.Writer, repository bootstrapAdminRepository, tenant string, random io.Reader, now func() time.Time, pollInterval time.Duration) error {
if repository == nil || strings.TrimSpace(tenant) == "" || random == nil || now == nil || pollInterval <= 0 {
return errors.New("bootstrap administrator command requires repository, tenant, random source, clock, and poll interval")
}
count, err := repository.AdministratorCount(ctx, tenant)
if err != nil {
return fmt.Errorf("check active administrators: %w", err)
}
if count > 0 {
return errors.New("an active administrator already exists; use administrator management instead")
}
codeNumber, err := cryptorand.Int(random, big.NewInt(1_000_000))
if err != nil {
return fmt.Errorf("generate bootstrap code: %w", err)
}
code := fmt.Sprintf("%06d", codeNumber.Int64())
expiresAt := now().Add(bootstrapAdminTTL)
if err := createBootstrapChallenge(ctx, repository, tenant, code, expiresAt); err != nil {
return fmt.Errorf("create bootstrap challenge: %w", err)
}
fmt.Fprintf(output, "首位管理员绑定已开始。\n绑定码:%s\n请在%s前私聊钉钉机器人发送:/setting %s\n终端正在等待绑定完成;按 Ctrl+C 取消。\n", code, expiresAt.Local().Format("2006-01-02 15:04:05"), code)
waitFor := expiresAt.Sub(now())
if waitFor <= 0 {
return errors.New("bootstrap binding expiry must be in the future")
}
waitCtx, cancel := context.WithTimeout(ctx, waitFor)
defer cancel()
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
err := repository.ConfirmBootstrap(waitCtx, tenant)
if err == nil {
fmt.Fprintln(output, "首位管理员绑定成功:候选用户已设为 super_admin。")
return nil
}
if !errors.Is(err, settings.ErrBootstrapCandidatePending) {
return fmt.Errorf("confirm bootstrap candidate: %w", err)
}
select {
case <-waitCtx.Done():
return errors.New("bootstrap binding expired or cancelled before a candidate was verified")
case <-ticker.C:
}
}
}
+88
View File
@@ -0,0 +1,88 @@
package main
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type bootstrapAdminRepoStub struct {
administratorCount int
createdCode string
createdExpiry time.Time
confirmCalls int
}
func (r *bootstrapAdminRepoStub) AdministratorCount(context.Context, string) (int, error) {
return r.administratorCount, nil
}
func (r *bootstrapAdminRepoStub) CreateBootstrapChallenge(_ context.Context, _ string, code string, expiry time.Time) error {
r.createdCode, r.createdExpiry = code, expiry
return nil
}
func (r *bootstrapAdminRepoStub) ConfirmBootstrap(context.Context, string) error {
r.confirmCalls++
if r.confirmCalls == 1 {
return settings.ErrBootstrapCandidatePending
}
return nil
}
func TestRunBootstrapAdminGeneratesCodeWaitsAndConfirmsCandidate(t *testing.T) {
now := time.Date(2026, time.September, 10, 8, 0, 0, 0, time.UTC)
repo := &bootstrapAdminRepoStub{}
var output bytes.Buffer
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
err := runBootstrapAdmin(ctx, &output, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), func() time.Time { return now }, time.Millisecond)
if err != nil {
t.Fatal(err)
}
if repo.createdCode != "000000" {
t.Fatalf("code=%q", repo.createdCode)
}
if !repo.createdExpiry.Equal(now.Add(10 * time.Minute)) {
t.Fatalf("expiry=%s", repo.createdExpiry)
}
if repo.confirmCalls < 2 {
t.Fatalf("confirm calls=%d", repo.confirmCalls)
}
for _, required := range []string{"/setting 000000", "绑定成功", "super_admin"} {
if !strings.Contains(output.String(), required) {
t.Fatalf("output=%q missing %q", output.String(), required)
}
}
}
func TestRunBootstrapAdminRefusesWhenAdministratorAlreadyExists(t *testing.T) {
repo := &bootstrapAdminRepoStub{administratorCount: 1}
err := runBootstrapAdmin(context.Background(), &bytes.Buffer{}, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), time.Now, time.Millisecond)
if err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("err=%v", err)
}
if repo.createdCode != "" || repo.confirmCalls != 0 {
t.Fatalf("repo mutated: %+v", repo)
}
}
func TestRunBootstrapAdminPropagatesChallengeCreationFailure(t *testing.T) {
repo := &bootstrapAdminRepoStub{}
original := createBootstrapChallenge
createBootstrapChallenge = func(context.Context, bootstrapAdminRepository, string, string, time.Time) error {
return errors.New("database unavailable")
}
t.Cleanup(func() { createBootstrapChallenge = original })
err := runBootstrapAdmin(context.Background(), &bytes.Buffer{}, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), time.Now, time.Millisecond)
if err == nil || !strings.Contains(err.Error(), "database unavailable") {
t.Fatalf("err=%v", err)
}
}
-81
View File
@@ -1,81 +0,0 @@
package main
import (
"context"
"errors"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type candidateConfigRepository interface {
CandidateDingTalkConfig(context.Context, string) (string, string, error)
ActivateDingTalkCandidate(context.Context, string, string) error
}
type candidateCredentialVerifier interface {
Verify(context.Context, string, string) dingtalkconfig.Result
}
var (
candidateTokenURL = ""
candidateTableBaseURL = ""
)
type dingTalkCandidateActivator struct {
tenant string
repo candidateConfigRepository
secrets *secretstore.Store
verifier candidateCredentialVerifier
bindings map[string]settings.ResourceBinding
restart func()
}
func (a *dingTalkCandidateActivator) ActivateCandidate(ctx context.Context) error {
if a == nil || a.repo == nil || a.secrets == nil || a.verifier == nil || a.restart == nil {
return errors.New("candidate_activation_unavailable")
}
clientID, secretRef, err := a.repo.CandidateDingTalkConfig(ctx, a.tenant)
if err != nil {
return errors.New("candidate_not_found")
}
secret, err := a.secrets.Get(secretRef)
if err != nil {
return errors.New("candidate_secret_unavailable")
}
if result := a.verifier.Verify(ctx, clientID, secret); !result.OK {
return errors.New("candidate_oauth_failed")
}
for _, kind := range []string{"question_table", "faq_table"} {
binding, ok := a.bindings[kind]
if !ok {
continue
}
client, err := questiontable.NewClient(questiontable.Config{ClientID: clientID, Secret: secret, BaseID: binding.BaseID, SheetID: binding.SheetID, OperatorID: binding.OperatorID, TokenURL: candidateTokenURL, BaseURL: candidateTableBaseURL}, nil)
if err != nil {
return errors.New("candidate_table_config_failed")
}
fields, err := client.ReadFields(ctx)
if err != nil {
return errors.New("candidate_table_probe_failed")
}
required := questiontable.QuestionSchema()
if kind == "faq_table" {
required = questiontable.FAQSchema()
}
if _, err = questiontable.MapRequiredFields(fields, required); err != nil {
return errors.New("candidate_table_schema_failed")
}
}
if err := a.repo.ActivateDingTalkCandidate(ctx, a.tenant, secretRef); err != nil {
return errors.New("candidate_database_activation_failed")
}
go func() {
time.Sleep(300 * time.Millisecond)
a.restart()
}()
return nil
}
-75
View File
@@ -1,75 +0,0 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type candidateRepoStub struct {
activated bool
ref string
}
func (r *candidateRepoStub) CandidateDingTalkConfig(context.Context, string) (string, string, error) {
return "app", r.ref, nil
}
func (r *candidateRepoStub) ActivateDingTalkCandidate(context.Context, string, string) error {
r.activated = true
return nil
}
type candidateVerifierStub struct{ ok bool }
func (v candidateVerifierStub) Verify(context.Context, string, string) dingtalkconfig.Result {
return dingtalkconfig.Result{OK: v.ok}
}
func TestCandidateActivatorRejectsOAuthWithoutActivation(t *testing.T) {
secrets, _ := secretstore.New(t.TempDir())
ref, _ := secrets.Put("dingtalk", "secret")
repo := &candidateRepoStub{ref: ref}
a := &dingTalkCandidateActivator{tenant: "t", repo: repo, secrets: secrets, verifier: candidateVerifierStub{}, bindings: map[string]settings.ResourceBinding{}, restart: func() {}}
if err := a.ActivateCandidate(context.Background()); err == nil {
t.Fatal("expected error")
}
if repo.activated {
t.Fatal("must not activate")
}
}
func TestCandidateActivatorValidatesSchemasThenActivates(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"accessToken":"token","expireIn":7200}`))
})
mux.HandleFunc("/v1.0/notable/bases/base/sheets/sheet/fields", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"value":[{"id":"q","name":"中文问题","type":"text"},{"id":"tm","name":"提问时间","type":"date"},{"id":"u","name":"提问人","type":"user"},{"id":"o","name":"原始问题","type":"text"},{"id":"a","name":"AI答案","type":"text"},{"id":"p","name":"流程触发人员信息","type":"text"},{"id":"l","name":"语言","type":"text"},{"id":"d","name":"部门","type":"text"},{"id":"r","name":"区域","type":"text"}]}`))
})
server := httptest.NewServer(mux)
defer server.Close()
secrets, _ := secretstore.New(t.TempDir())
ref, _ := secrets.Put("dingtalk", "secret")
repo := &candidateRepoStub{ref: ref}
restarted := make(chan struct{}, 1)
a := &dingTalkCandidateActivator{tenant: "t", repo: repo, secrets: secrets, verifier: candidateVerifierStub{ok: true}, bindings: map[string]settings.ResourceBinding{"question_table": {BaseID: "base", SheetID: "sheet", OperatorID: "u"}}, restart: func() { restarted <- struct{}{} }}
oldTokenURL, oldBaseURL := candidateTokenURL, candidateTableBaseURL
candidateTokenURL, candidateTableBaseURL = server.URL+"/token", server.URL
defer func() { candidateTokenURL, candidateTableBaseURL = oldTokenURL, oldBaseURL }()
if err := a.ActivateCandidate(context.Background()); err != nil {
t.Fatal(err)
}
if !repo.activated {
t.Fatal("expected activation")
}
select {
case <-restarted:
case <-time.After(time.Second):
t.Fatal("restart not requested")
}
}
+7 -34
View File
@@ -12,7 +12,6 @@ import (
"syscall"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/agentbinding"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
@@ -45,6 +44,12 @@ func runtimeBuildInfo() httpapi.BuildInfo {
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
if len(os.Args) == 2 && os.Args[1] == "bootstrap-admin" {
if err := runBootstrapAdminCommand(ctx, os.Stdout); err != nil {
log.Fatal(err)
}
return
}
var restartRequested atomic.Bool
storeCtx, storeCancel := context.WithTimeout(ctx, 10*time.Second)
defer storeCancel()
@@ -64,8 +69,7 @@ func main() {
address = "127.0.0.1:8090"
}
if strings.TrimSpace(settingsRuntime.clientID) == "" || strings.TrimSpace(settingsRuntime.secret) == "" {
runSetupOnly(ctx, cancel, settingsRuntime, address, os.Getenv("DOBOTAI_TENANT_KEY"), &restartRequested)
return
log.Fatal("DingTalk credentials are required; configure DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET in the protected runtime environment")
}
client, err := weknora.NewClient(os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"), 120*time.Second)
if err != nil {
@@ -131,11 +135,6 @@ func main() {
if err != nil {
log.Fatal(err)
}
settingsRuntime.admin.SetCandidateActivator(&dingTalkCandidateActivator{
tenant: dingCfg.TenantKey, repo: settingsRuntime.repository, secrets: settingsRuntime.secrets,
verifier: settingsRuntime.verifier, bindings: activeBindings,
restart: func() { restartRequested.Store(true); cancel() },
})
runtimeReady := make(chan struct{})
faqCfg, err := readFAQConfig()
if err != nil {
@@ -329,37 +328,11 @@ func main() {
log.Printf("DingTalk Stream ingress started for tenant %s", dingCfg.TenantKey)
}
close(runtimeReady)
_, questionBound := activeBindings["question_table"]
_, faqBound := activeBindings["faq_table"]
_, supportBound := activeBindings["support_group"]
weknoraHealthURL := strings.TrimRight(strings.TrimSpace(os.Getenv("WEKNORA_BASE_URL")), "/")
if weknoraHealthURL != "" {
weknoraHealthURL += "/health"
}
settingsRuntime.admin.SetOperationalStatusProvider(&operationalStatusProvider{
metrics: store,
tenant: dingCfg.TenantKey,
weknoraHealthURL: weknoraHealthURL,
httpClient: &http.Client{Timeout: 3 * time.Second},
now: time.Now,
version: runtimeBuildInfo().Version,
streamRunning: streamClient != nil,
questionRunning: questionTableDone != nil,
faqRunning: faqWorkflowDone != nil,
supportRunning: supportDispatcher != nil,
questionBound: questionBound,
faqBound: faqBound,
supportBound: supportBound,
ossConfigured: outboundOSSConfigured(),
})
rootHandler := http.NewServeMux()
rootHandler.Handle("/", api.Handler())
if outboundImages.handler != nil {
rootHandler.Handle(outboundImageRoute, outboundImages.handler)
}
if strings.TrimSpace(os.Getenv("DOBOTAI_ADMIN_TOKEN")) != "" {
rootHandler.Handle("/admin/", adminweb.New(settingsRuntime.admin, os.Getenv("DOBOTAI_ADMIN_TOKEN")))
}
server := &http.Server{Addr: address, Handler: rootHandler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 130 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
log.Printf("DobotAI ingress listening on %s", address)
+16
View File
@@ -183,3 +183,19 @@ func TestOutboundOSSConfiguredRequiresStablePublicBaseURL(t *testing.T) {
t.Fatal("complete stable image configuration should be active")
}
}
func TestMainDoesNotExposeWebAdministrationOrSetupOnlyMode(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, forbidden := range []string{"internal/adminweb", "adminweb.", `"/admin/"`, "runSetupOnly"} {
if strings.Contains(source, forbidden) {
t.Fatalf("main must not expose web administration: found %q", forbidden)
}
}
if !strings.Contains(source, "DingTalk credentials are required") {
t.Fatal("main must fail closed when DingTalk credentials are absent")
}
}
-164
View File
@@ -1,164 +0,0 @@
package main
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
)
type adminComponent = adminweb.Component
type operationalMetrics = postgres.OperationalMetrics
type operationalMetricsSource interface {
Ping(context.Context) error
ReadOperationalMetrics(context.Context, string) (postgres.OperationalMetrics, error)
}
type operationalStatusProvider struct {
metrics operationalMetricsSource
tenant string
weknoraHealthURL string
httpClient *http.Client
now func() time.Time
version string
streamRunning bool
questionRunning bool
faqRunning bool
supportRunning bool
questionBound bool
faqBound bool
supportBound bool
ossConfigured bool
}
func (p *operationalStatusProvider) Components(ctx context.Context) ([]adminweb.Component, error) {
now := time.Now()
if p.now != nil {
now = p.now()
}
components := []adminweb.Component{{Name: "运行版本", State: "ok", Code: "version_ok", Detail: "版本:" + safeVersion(p.version), LastSuccess: now, CheckedAt: now}}
metrics := operationalMetrics{}
if p.metrics == nil || p.metrics.Ping(ctx) != nil {
components = append(components, adminweb.Component{Name: "PostgreSQL", State: "error", Code: "database_unavailable", Detail: "数据库健康检查失败", CheckedAt: now})
} else {
var err error
metrics, err = p.metrics.ReadOperationalMetrics(ctx, p.tenant)
if err != nil {
components = append(components, adminweb.Component{Name: "PostgreSQL", State: "error", Code: "metrics_query_failed", Detail: "数据库可连接,但状态聚合失败", LastSuccess: now, CheckedAt: now})
} else {
components = append(components, adminweb.Component{Name: "PostgreSQL", State: "ok", Code: "database_ok", Detail: "连接与状态聚合正常", LastSuccess: now, CheckedAt: now})
}
}
components = append(components,
booleanComponent("钉钉Stream", p.streamRunning, "stream_running", "stream_stopped", "正式Stream消费者运行中", "正式Stream消费者未运行", now),
p.weknoraComponent(ctx, now),
outboxComponent("问题表Outbox", metrics.QuestionPending, metrics.QuestionFailed, metrics.LastQuestionSuccess, now),
outboxComponent("FAQ Outbox", metrics.FAQPending, metrics.FAQFailed, metrics.LastFAQSuccess, now),
supportComponent(p, metrics, now),
dispatcherComponent("问题表Dispatcher", p.questionBound, p.questionRunning, now),
dispatcherComponent("FAQ Dispatcher", p.faqBound, p.faqRunning, now),
booleanComponent("Private OSS图片", p.ossConfigured, "oss_configured", "oss_unconfigured", "图片出站配置完整", "图片出站未配置", now),
)
return components, nil
}
func (p *operationalStatusProvider) weknoraComponent(ctx context.Context, now time.Time) adminweb.Component {
component := adminweb.Component{Name: "WeKnora", State: "error", Code: "health_probe_failed", Detail: "健康探针失败", CheckedAt: now}
if strings.TrimSpace(p.weknoraHealthURL) == "" {
component.Code = "health_url_missing"
component.Detail = "健康探针地址未配置"
return component
}
client := p.httpClient
if client == nil {
client = &http.Client{Timeout: 3 * time.Second}
}
reqCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, p.weknoraHealthURL, nil)
if err != nil {
return component
}
resp, err := client.Do(req)
if err != nil {
return component
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
component.Code = "health_http_error"
component.Detail = fmt.Sprintf("健康探针HTTP %d", resp.StatusCode)
return component
}
component.State, component.Code, component.Detail = "ok", "health_ok", "HTTP健康探针正常"
component.LastSuccess = now
return component
}
func outboxComponent(name string, pending, failed int, lastSuccess, now time.Time) adminweb.Component {
component := adminweb.Component{Name: name, State: "ok", Code: "outbox_empty", Detail: "pending=0,failed=0", LastSuccess: lastSuccess, CheckedAt: now}
if failed > 0 {
component.State, component.Code = "error", "outbox_failed"
} else if pending > 0 {
component.State, component.Code = "warning", "outbox_pending"
}
component.Detail = fmt.Sprintf("pending=%d,failed=%d", pending, failed)
return component
}
func supportComponent(p *operationalStatusProvider, metrics operationalMetrics, now time.Time) adminweb.Component {
component := adminweb.Component{Name: "技术支持通知", CheckedAt: now, LastSuccess: metrics.LastSupportSuccess}
switch {
case !p.supportBound:
component.State, component.Code, component.Detail = "disabled", "binding_missing", "技术支持群未绑定"
case !p.faqBound:
component.State, component.Code, component.Detail = "blocked", "faq_binding_required", "已绑定支持群;需先激活FAQ表"
case metrics.SupportFailed > 0:
component.State, component.Code = "error", "notification_failed"
component.Detail = fmt.Sprintf("pending=%d,failed=%d", metrics.SupportPending, metrics.SupportFailed)
case metrics.SupportPending > 0:
component.State, component.Code = "warning", "notification_pending"
component.Detail = fmt.Sprintf("pending=%d,failed=0", metrics.SupportPending)
case p.supportRunning:
component.State, component.Code, component.Detail = "ok", "dispatcher_running", "通知Dispatcher运行中;pending=0,failed=0"
default:
component.State, component.Code, component.Detail = "error", "dispatcher_stopped", "绑定与依赖完整,但Dispatcher未运行"
}
return component
}
func dispatcherComponent(name string, bound, running bool, now time.Time) adminweb.Component {
if !bound {
return adminweb.Component{Name: name, State: "disabled", Code: "binding_missing", Detail: "无active绑定", CheckedAt: now}
}
if !running {
return adminweb.Component{Name: name, State: "error", Code: "dispatcher_stopped", Detail: "已有active绑定,但Dispatcher未运行", CheckedAt: now}
}
return adminweb.Component{Name: name, State: "ok", Code: "dispatcher_running", Detail: "Dispatcher运行中", LastSuccess: now, CheckedAt: now}
}
func booleanComponent(name string, ok bool, successCode, failureCode, successDetail, failureDetail string, now time.Time) adminweb.Component {
if ok {
return adminweb.Component{Name: name, State: "ok", Code: successCode, Detail: successDetail, LastSuccess: now, CheckedAt: now}
}
return adminweb.Component{Name: name, State: "disabled", Code: failureCode, Detail: failureDetail, CheckedAt: now}
}
func safeVersion(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "unknown"
}
if len(value) > 80 {
return value[:80]
}
return value
}
-105
View File
@@ -1,105 +0,0 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestOperationalStatusProviderBuildsLiveComponents(t *testing.T) {
health := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer health.Close()
checked := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
provider := &operationalStatusProvider{
metrics: operationalMetricsStub{value: operationalMetrics{
QuestionPending: 2, FAQFailed: 1, SupportPending: 3,
LastQuestionSuccess: checked.Add(-time.Minute),
}},
weknoraHealthURL: health.URL,
httpClient: health.Client(),
now: func() time.Time { return checked },
version: "settings-test",
streamRunning: true,
questionRunning: true,
faqRunning: false,
supportRunning: false,
questionBound: true,
faqBound: false,
supportBound: true,
ossConfigured: true,
}
components, err := provider.Components(context.Background())
if err != nil {
t.Fatal(err)
}
byName := componentMap(components)
assertComponent(t, byName, "运行版本", "ok", "version_ok")
assertComponent(t, byName, "PostgreSQL", "ok", "database_ok")
assertComponent(t, byName, "钉钉Stream", "ok", "stream_running")
assertComponent(t, byName, "WeKnora", "ok", "health_ok")
assertComponent(t, byName, "问题表Outbox", "warning", "outbox_pending")
assertComponent(t, byName, "FAQ Outbox", "error", "outbox_failed")
assertComponent(t, byName, "技术支持通知", "blocked", "faq_binding_required")
assertComponent(t, byName, "问题表Dispatcher", "ok", "dispatcher_running")
assertComponent(t, byName, "FAQ Dispatcher", "disabled", "binding_missing")
assertComponent(t, byName, "Private OSS图片", "ok", "oss_configured")
for _, component := range components {
for _, forbidden := range []string{health.URL, "cid-secret-group", "base-secret", "access-token"} {
if strings.Contains(component.Detail, forbidden) {
t.Fatalf("component %q leaks sensitive identifier %q", component.Name, forbidden)
}
}
}
}
func TestOperationalStatusProviderIsolatesWeKnoraFailure(t *testing.T) {
provider := &operationalStatusProvider{
metrics: operationalMetricsStub{value: operationalMetrics{}},
weknoraHealthURL: "http://127.0.0.1:1/health",
httpClient: &http.Client{Timeout: 50 * time.Millisecond},
now: time.Now,
}
components, err := provider.Components(context.Background())
if err != nil {
t.Fatal(err)
}
byName := componentMap(components)
assertComponent(t, byName, "PostgreSQL", "ok", "database_ok")
assertComponent(t, byName, "WeKnora", "error", "health_probe_failed")
}
type operationalMetricsStub struct {
value operationalMetrics
err error
}
func (s operationalMetricsStub) ReadOperationalMetrics(context.Context, string) (operationalMetrics, error) {
return s.value, s.err
}
func (s operationalMetricsStub) Ping(context.Context) error { return s.err }
func componentMap(values []adminComponent) map[string]adminComponent {
out := make(map[string]adminComponent, len(values))
for _, value := range values {
out[value.Name] = value
}
return out
}
func assertComponent(t *testing.T, values map[string]adminComponent, name, state, code string) {
t.Helper()
value, ok := values[name]
if !ok {
t.Fatalf("missing component %q: %+v", name, values)
}
if value.State != state || value.Code != code {
t.Fatalf("component %q=%+v want state=%q code=%q", name, value, state, code)
}
}
+8 -20
View File
@@ -7,9 +7,7 @@ import (
"path/filepath"
"strings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
@@ -29,16 +27,12 @@ func (r *dingTalkSettingsRouter) Handle(ctx context.Context, m dingtalk.Settings
}
type settingsRuntime struct {
repository *postgres.SettingsRepository
bindings *validatedSettingsRepository
controller *settings.Controller
admin *adminweb.ControlService
clientID string
secret string
secretRef string
secrets *secretstore.Store
verifier *dingtalkconfig.Verifier
pendingActivation bool
repository *postgres.SettingsRepository
bindings *validatedSettingsRepository
controller *settings.Controller
clientID string
secret string
secretRef string
}
func buildSettingsRuntime(ctx context.Context, store *postgres.Store, tenantKey string) (*settingsRuntime, error) {
@@ -62,8 +56,7 @@ func buildSettingsRuntime(ctx context.Context, store *postgres.Store, tenantKey
if err != nil {
return nil, err
}
pendingActivation, err := repo.PrepareDingTalkRuntimeStart(ctx, tenantKey)
if err != nil {
if _, err := repo.PrepareDingTalkRuntimeStart(ctx, tenantKey); err != nil {
return nil, err
}
clientID, secretRef, _, err := repo.ActiveDingTalkConfig(ctx, tenantKey)
@@ -88,11 +81,6 @@ func buildSettingsRuntime(ctx context.Context, store *postgres.Store, tenantKey
return nil, err
}
}
verifier := dingtalkconfig.NewVerifier("https://oapi.dingtalk.com/gettoken", nil)
control, err := adminweb.NewControlService(tenantKey, repo, secrets, verifier)
if err != nil {
return nil, err
}
validatedRepo := &validatedSettingsRepository{SettingsRepository: repo, clientID: clientID, secret: secret}
return &settingsRuntime{repository: repo, bindings: validatedRepo, controller: settings.NewController(validatedRepo), admin: control, clientID: clientID, secret: secret, secretRef: secretRef, secrets: secrets, verifier: verifier, pendingActivation: pendingActivation}, nil
return &settingsRuntime{repository: repo, bindings: validatedRepo, controller: settings.NewController(validatedRepo), clientID: clientID, secret: secret, secretRef: secretRef}, nil
}
-65
View File
@@ -1,65 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"strings"
"sync/atomic"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
func newSetupOnlyHandler(runtime *settingsRuntime, token string) (http.Handler, error) {
if runtime == nil || runtime.admin == nil {
return nil, errors.New("setup runtime unavailable")
}
if strings.TrimSpace(token) == "" {
return nil, errors.New("DOBOTAI_ADMIN_TOKEN is required in setup-only mode")
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok", "mode": "setup_required"})
})
mux.HandleFunc("GET /version", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(runtimeBuildInfo())
})
mux.Handle("/admin/", adminweb.New(runtime.admin, token))
return mux, nil
}
func runSetupOnly(ctx context.Context, cancel context.CancelFunc, runtime *settingsRuntime, address, tenant string, restartRequested *atomic.Bool) {
runtime.admin.SetCandidateActivator(&dingTalkCandidateActivator{
tenant: tenant, repo: runtime.repository, secrets: runtime.secrets, verifier: runtime.verifier,
bindings: map[string]settings.ResourceBinding{},
restart: func() { restartRequested.Store(true); cancel() },
})
handler, err := newSetupOnlyHandler(runtime, os.Getenv("DOBOTAI_ADMIN_TOKEN"))
if err != nil {
log.Fatal(err)
}
server := &http.Server{Addr: address, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 20 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
log.Printf("DobotAI setup-only listener on %s", address)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("setup HTTP server failed: %v", err)
cancel()
}
}()
<-ctx.Done()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("setup HTTP shutdown: %v", err)
}
if restartRequested.Load() {
os.Exit(75)
}
}
-102
View File
@@ -1,102 +0,0 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type setupRepoStub struct{}
func (setupRepoStub) ActiveDingTalkConfig(context.Context, string) (string, string, bool, error) {
return "", "", false, nil
}
func (setupRepoStub) CandidateDingTalkConfig(context.Context, string) (string, string, error) {
return "", "", nil
}
func (setupRepoStub) SaveDingTalkConfig(context.Context, string, string, string, bool) error {
return nil
}
func (setupRepoStub) MarkDingTalkCandidateFailed(context.Context, string, string, string) error {
return nil
}
func (setupRepoStub) AdministratorCount(context.Context, string) (int, error) { return 0, nil }
func (setupRepoStub) ActiveBindings(context.Context, string) (map[string]settings.ResourceBinding, error) {
return map[string]settings.ResourceBinding{}, nil
}
func (setupRepoStub) CreateBootstrapChallenge(context.Context, string, string, time.Time) error {
return nil
}
func (setupRepoStub) ConfirmBootstrap(context.Context, string) error { return nil }
type setupVerifierStub struct{}
func (setupVerifierStub) Verify(context.Context, string, string) dingtalkconfig.Result {
return dingtalkconfig.Result{}
}
func setupControl(t *testing.T) *adminweb.ControlService {
t.Helper()
secrets, err := secretstore.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
control, err := adminweb.NewControlService("tenant", setupRepoStub{}, secrets, setupVerifierStub{})
if err != nil {
t.Fatal(err)
}
return control
}
func TestSetupOnlyHandlerExposesHealthVersionAndProtectedAdmin(t *testing.T) {
runtime := &settingsRuntime{admin: setupControl(t)}
h, err := newSetupOnlyHandler(runtime, "admin-token")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("health status=%d", resp.Code)
}
var health map[string]string
if err = json.Unmarshal(resp.Body.Bytes(), &health); err != nil {
t.Fatal(err)
}
if health["status"] != "ok" || health["mode"] != "setup_required" {
t.Fatalf("health=%v", health)
}
resp = httptest.NewRecorder()
h.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/version", nil))
if resp.Code != http.StatusOK {
t.Fatalf("version status=%d", resp.Code)
}
resp = httptest.NewRecorder()
h.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/admin/settings", nil))
if resp.Code != http.StatusUnauthorized {
t.Fatalf("unauth status=%d", resp.Code)
}
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.Header.Set("Authorization", "Bearer admin-token")
resp = httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("auth status=%d body=%s", resp.Code, resp.Body.String())
}
}
func TestSetupOnlyHandlerRequiresAdminToken(t *testing.T) {
_, err := newSetupOnlyHandler(&settingsRuntime{admin: setupControl(t)}, "")
if err == nil {
t.Fatal("expected token error")
}
}
@@ -0,0 +1,18 @@
-- 000021_coverage_non_knowledge_response_reason down migration.
-- Map the v4 covered reason to the pre-v4 covered code before restoring the
-- legacy allowlist, so rollback remains valid with persisted v4 decisions.
UPDATE coverage_decisions
SET reason_code = 'covered_has_references'
WHERE reason_code = 'covered_non_knowledge_response';
ALTER TABLE coverage_decisions
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
ALTER TABLE coverage_decisions
ADD CONSTRAINT coverage_decisions_reason_code_check
CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer',
'covered_visual_recognition'
));
@@ -0,0 +1,14 @@
-- 000021_coverage_non_knowledge_response_reason: distinguish text turns that
-- did not invoke knowledge search from actual retrieval misses.
ALTER TABLE coverage_decisions
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
ALTER TABLE coverage_decisions
ADD CONSTRAINT coverage_decisions_reason_code_check
CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer',
'covered_visual_recognition',
'covered_non_knowledge_response'
));
@@ -0,0 +1,44 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp021AddsCoveredNonKnowledgeResponseReason(t *testing.T) {
data, err := os.ReadFile("000021_coverage_non_knowledge_response_reason.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"ALTER TABLE COVERAGE_DECISIONS",
"DROP CONSTRAINT IF EXISTS COVERAGE_DECISIONS_REASON_CODE_CHECK",
"COVERED_NON_KNOWLEDGE_RESPONSE",
"COVERED_VISUAL_RECOGNITION",
} {
if !strings.Contains(source, required) {
t.Errorf("up migration missing %q", required)
}
}
}
func TestDown021MapsNewCoveredReasonBeforeRestoringLegacyConstraint(t *testing.T) {
data, err := os.ReadFile("000021_coverage_non_knowledge_response_reason.down.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"UPDATE COVERAGE_DECISIONS",
"COVERED_NON_KNOWLEDGE_RESPONSE",
"COVERED_HAS_REFERENCES",
"ALTER TABLE COVERAGE_DECISIONS",
"DROP CONSTRAINT IF EXISTS COVERAGE_DECISIONS_REASON_CODE_CHECK",
} {
if !strings.Contains(source, required) {
t.Errorf("down migration missing %q", required)
}
}
}
+262
View File
@@ -0,0 +1,262 @@
# DobotAI DingTalk Docker 部署手册
> 适用范围:DobotAI 的 Docker Compose 部署。容器名固定为 `dobotai-dingtalk`,数据库容器名固定为 `dobotai-postgres`。
>
> 本文面向测试/预发布。生产必须使用已验收的同一镜像、独立备份和独立审批,禁止在生产重新编译。
## 1. 定位
DobotAI 是钉钉渠道的对话编排与交付层,不是知识库、文档解析器或模型平台。
```text
DobotAI 负责:
钉钉 Stream、富文本/图片、会话映射、WeKnora Agent 调用、卡片交付、
/think、/setting、覆盖判定、Support Case、反馈、渠道审计。
DobotAI 不负责:
WeKnora 文档/Chunk/向量/图谱/解析/模型;钉钉应用创建;知识库治理。
```
## 2. Docker 架构
```text
钉钉用户/群
│ DingTalk Stream(出站长连接)
▼
┌────────────────────────────────────────┐
│ dobotai-dingtalk │
│ 消息、会话、卡片、覆盖与支持流程 │
│ 仅映射到宿主机 127.0.0.1:8090 │
└───────┬──────────────────────┬───────────┘
│ Compose 网络 │ host.docker.internal
▼ ▼
┌────────────────────┐ 已有 WeKnora 服务
│ dobotai-postgres │ Agent / 检索 / 知识 / 模型
│ dobotai 独立数据库 │
│ 命名卷持久化 │
└────────────────────┘
```
Compose 管理的资源:
| 资源 | 名称 | 数据策略 |
| --- | --- | --- |
| 应用容器 | `dobotai-dingtalk` | 可重建,不保存业务数据卷 |
| PostgreSQL容器 | `dobotai-postgres` | 可重建,数据在命名卷 |
| PostgreSQL命名卷 | `dobotai-postgres-data` | 持久数据,禁止常规删除 |
| Compose网络 | `dobotai-network` | 应用与数据库内部通信 |
| 宿主机密钥目录 | `<DOBOTAI_HOME>/secrets` | bind mount,不进入Git |
关键Compose属性:
```yaml
services:
dobotai-postgres:
container_name: dobotai-postgres
volumes:
- dobotai-postgres-data:/var/lib/postgresql/data
dobotai-dingtalk:
container_name: dobotai-dingtalk
depends_on:
dobotai-postgres:
condition: service_healthy
ports:
- "127.0.0.1:8090:8090"
extra_hosts:
- "host.docker.internal:host-gateway"
```
应用不使用 `network_mode: host`:它以`dobotai-postgres:5432`访问Compose数据库,以`http://host.docker.internal:8080`访问宿主机的WeKnora。
## 3. 自动安装准备
`install-docker.sh`自动完成:
```text
检查 Docker、Docker Compose 和已导入的候选镜像
创建 <DOBOTAI_HOME>/container、config、secrets、backups、migrations
生成权限0600的 config/postgres.env 与 config/dobotai.env
生成独立数据库密码、Ingress Token、Admin Token
创建并启动 dobotai-postgres
等待 PostgreSQL healthcheck
按顺序应用 db/migrations/*.up.sql
在 dobotai_schema_migrations 记录版本、名称和时间
安装 configure / start / bind-admin 三条短命令
```
它不会:
```text
启动尚未配置的钉钉应用容器
覆盖已有配置或数据库数据
删除任何 Docker 卷
输出密码、API Key、Token 或连接串
```
默认安装根目录:
```text
/opt/dobotai
├── container/ Compose、脚本、SQL迁移、运行参数
├── config/ dobotai.env、postgres.env(0600)
├── secrets/ DobotAI SecretStore(0700)
└── backups/ 逻辑备份和SHA清单
```
可以用`DOBOTAI_HOME=/绝对路径`改安装位置。测试环境建议使用独立目录,例如`/opt/dobotai-test-compose`。
## 4. 前置条件
```text
部署用户具有 sudo 和 Docker daemon 访问权限
宿主机 WeKnora 已健康,容器可经 host.docker.internal:8080 访问
```
Docker与Docker Compose缺失时由根安装脚本自动安装(当前支持APT系统)。不必手工构建、导入或命名镜像。
## 5. 全新安装
从源码仓库克隆后,进入仓库根目录执行唯一安装入口:
```bash
sudo bash ./install-docker.sh
```
脚本内部以当前Git提交构建固定内部镜像名:
```text
dobotai-dingtalk:local-<当前Git短SHA>
```
镜像名、PostgreSQL镜像和构建元数据只由根目录`install-docker.sh`维护,用户不传`DOBOTAI_IMAGE`。脚本完成后数据库和迁移已就绪,应用尚未启动。然后执行:
构建期Go依赖使用脚本内置的公开模块代理`https://goproxy.cn,direct`;它只存在于镜像构建命令,不写入DobotAI运行配置。
```bash
sudo dobotai-dingtalk-configure
```
配置器逐项显示用途和脱敏格式示例;已有值不会回显,直接回车表示保留。数据库连接串、数据库密码、Ingress Token和Admin Token由安装脚本生成,交互配置器不要求输入。
主要业务连接配置:
| 变量 | 用途 | 示例 |
| --- | --- | --- |
| `WEKNORA_BASE_URL` | Docker内访问宿主机WeKnora | `http://host.docker.internal:8080` |
| `WEKNORA_API_KEY` | DobotAI专用WeKnora API Key | `<你的WeKnora API Key>` |
| `DOBOTAI_TENANT_KEY` | DobotAI租户隔离键 | `dobotai` |
| `DOBOTAI_AGENT_BINDINGS` | 渠道到WeKnora Agent映射 | `{"dingtalk:*":"你的Agent ID"}` |
| `DINGTALK_CLIENT_ID` | 钉钉应用Client ID/AppKey | `dingxxxxxxxx` |
| `DINGTALK_CLIENT_SECRET` | 钉钉应用Secret/AppSecret | `<你的钉钉 AppSecret>` |
配置器成功后自动启动`dobotai-dingtalk`,并检查PostgreSQL健康、`http://127.0.0.1:8090/healthz`和容器运行状态。
## 7. 首位管理员绑定
仅在租户没有有效管理员时执行:
```bash
sudo dobotai-dingtalk-bind-admin
```
终端显示10分钟有效的一次性六码。候选管理员私聊钉钉机器人:
```text
/setting <六码绑定码>
```
终端自动确认后写入`super_admin/active`。已有管理员时命令拒绝执行,防止意外提权。
## 8. 迁移现有DobotAI实例
旧实例已有Turn、会话、Support Case、管理员或反馈时,不能直接新建空库后切换。先安装目标Compose基础设施,再运行:
```bash
sudo DOBOTAI_HOME=/opt/dobotai-compose \
SOURCE_POSTGRES_CONTAINER=dobotai-test-postgres \
SOURCE_APP_CONTAINER=dobotai-dingtalk \
SOURCE_DOBOTAI_ENV=/etc/dobotai-test/dobotai.env \
/opt/dobotai-compose/container/migrate-existing-database.sh
```
迁移脚本固定顺序:
```text
启动目标 dobotai-postgres 并等待健康
→ 停止并改名旧应用容器(不删除)
→ pg_dump -Fc 导出旧数据库
→ pg_restore --list 验证备份可读
→ 写入备份SHA-256
→ 复制旧应用配置,只替换数据库、监听和WeKnora地址
→ 恢复到 dobotai-postgres-data
→ 应用缺失迁移
→ 启动新 dobotai-dingtalk
→ 健康失败则恢复旧容器名称和旧容器
```
源数据库不会删除。成功后旧应用容器会以`*-legacy-*`保留,确认稳定前不得删除旧容器或旧数据库。
## 9. 数据库迁移
DobotAI应用本身不扫描SQL。安装/迁移流程由:
```bash
sudo /opt/dobotai/container/apply-migrations.sh
```
执行`migrations/*.up.sql`,每个成功迁移在同一事务写入:
```text
dobotai_schema_migrations(version, name, applied_at)
```
任何数据库升级前都必须有经`pg_restore --list`验证的逻辑备份。`.down.sql`只能在审查通过后用于数据库回滚,不能用应用镜像回滚替代数据库回滚。
## 10. 日常运行与升级
```bash
# 健康与版本
curl --noproxy '*' -fsS http://127.0.0.1:8090/healthz
curl --noproxy '*' -fsS http://127.0.0.1:8090/version
# Compose状态
sudo docker compose --env-file /opt/dobotai/container/runtime.env \
-f /opt/dobotai/container/compose.yaml ps
# 日志:不要转发含用户内容或凭据的原始上下文
sudo docker logs --since 10m dobotai-dingtalk
# 数据库状态
sudo docker inspect -f '{{.State.Health.Status}}|{{.RestartCount}}' dobotai-postgres
```
升级顺序:导入候选镜像 → 备份并应用必要迁移 → 更新`runtime.env`中的`DOBOTAI_IMAGE` → 执行:
```bash
sudo dobotai-dingtalk-start
```
应用健康失败时启动器恢复上一应用镜像;数据库迁移仍必须按审查过的SQL回滚流程处理。
## 11. 覆盖判定边界
```text
有结构化引用 → covered_has_references
实际检索过但没有引用 → uncovered_no_references → Support Case
明确无权威知识/无答案 → uncovered_explicit_no_answer → Support Case
图片未检索、纯视觉识别 → covered_visual_recognition
文本未检索、闲聊/普通非知识响应 → covered_non_knowledge_response
```
因此“在吗”“你好”“谢谢”等未触发知识检索的文本不会创建Support Case或发送未覆盖提醒。
## 12. 禁止操作
```text
docker compose down -v
docker volume rm dobotai-postgres-data
docker system prune --volumes
把 config/*.env、secrets/、备份Dump、真实Token或凭据提交Git
在生产重新编译或使用未经测试的镜像
```
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
compose_file="$container_dir/compose.yaml"
runtime_env="$container_dir/runtime.env"
migrations_dir="$container_dir/migrations"
if [[ ! -r "$compose_file" || ! -r "$runtime_env" || ! -d "$migrations_dir" ]]; then
echo "迁移运行文件未安装。请先执行 install-docker.sh。" >&2
exit 1
fi
compose=(docker compose --env-file "$runtime_env" -f "$compose_file")
"${compose[@]}" up -d --no-build dobotai-postgres
for _ in $(seq 1 30); do
status=$(docker inspect -f '{{.State.Health.Status}}' dobotai-postgres 2>/dev/null || true)
[[ "$status" == "healthy" ]] && break
sleep 1
done
[[ "${status:-}" == "healthy" ]] || { echo "PostgreSQL 未健康,拒绝迁移。" >&2; exit 1; }
psql_db() {
"${compose[@]}" exec -T dobotai-postgres psql -v ON_ERROR_STOP=1 -U dobotai -d dobotai "$@"
}
psql_db -c 'CREATE TABLE IF NOT EXISTS dobotai_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW())'
for file in "$migrations_dir"/*.up.sql; do
[[ -f "$file" ]] || continue
base="$(basename "$file")"
version_text="${base%%_*}"
version=$((10#$version_text))
name="${base#*_}"
name="${name%.up.sql}"
case "$name" in *[!a-z0-9_]* ) echo "非法迁移文件名:$base" >&2; exit 1;; esac
applied=$(psql_db -At -c "SELECT EXISTS(SELECT 1 FROM dobotai_schema_migrations WHERE version=$version)")
[[ "$applied" == "t" ]] && continue
{
printf 'BEGIN;\n'
cat "$file"
printf "\nINSERT INTO dobotai_schema_migrations(version,name) VALUES (%s,'%s');\nCOMMIT;\n" "$version" "$name"
} | "${compose[@]}" exec -T dobotai-postgres psql -v ON_ERROR_STOP=1 -U dobotai -d dobotai
echo "已应用迁移:$base"
done
+56
View File
@@ -0,0 +1,56 @@
services:
dobotai-postgres:
image: ${DOBOTAI_POSTGRES_IMAGE:-postgres:16-alpine}
container_name: dobotai-postgres
env_file:
- ${DOBOTAI_POSTGRES_ENV_FILE:?set DOBOTAI_POSTGRES_ENV_FILE in runtime.env}
volumes:
- dobotai-postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 3s
retries: 30
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- dobotai-network
dobotai-dingtalk:
image: ${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE in runtime.env}
container_name: dobotai-dingtalk
depends_on:
dobotai-postgres:
condition: service_healthy
env_file:
- ${DOBOTAI_ENV_FILE:?set DOBOTAI_ENV_FILE in runtime.env}
environment:
DOBOTAI_SECRET_DIR: /var/lib/dobotai/secrets
volumes:
- type: bind
source: ${DOBOTAI_SECRET_DIR_HOST:?set DOBOTAI_SECRET_DIR_HOST in runtime.env}
target: /var/lib/dobotai/secrets
read_only: false
ports:
- "127.0.0.1:8090:8090"
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
init: true
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=32m
security_opt:
- no-new-privileges:true
networks:
- dobotai-network
volumes:
dobotai-postgres-data:
name: dobotai-postgres-data
networks:
dobotai-network:
name: dobotai-network
driver: bridge
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
import argparse
import getpass
import os
from pathlib import Path
import sys
FIELDS = (
("WEKNORA_BASE_URL", "WeKnora 地址", "Docker 容器访问宿主机 WeKnora 时使用 host.docker.internal。", "http://host.docker.internal:8080", False),
("WEKNORA_API_KEY", "WeKnora API Key", "在 WeKnora 中为 DobotAI 创建的独立 API Key。", "<你的WeKnora API Key>", True),
("DOBOTAI_INGRESS_TOKEN", "DobotAI Ingress Token", "调用 /v1/qa 时使用的独立长随机令牌。", "<随机长字符串>", True),
("DOBOTAI_TENANT_KEY", "DobotAI 租户键", "同一 DobotAI 数据库内的渠道隔离键。", "dobotai", False),
("DOBOTAI_AGENT_BINDINGS", "DobotAI Agent Bindings JSON", "钉钉会话匹配到 WeKnora Agent 的映射。", '{"dingtalk:*":"你的Agent ID"}', False),
("DINGTALK_CLIENT_ID", "钉钉 Client ID", "钉钉应用的 Client ID/AppKey。", "dingxxxxxxxx", False),
("DINGTALK_CLIENT_SECRET", "钉钉 Client Secret", "钉钉应用的 Client Secret/AppSecret。", "<你的钉钉 AppSecret>", True),
)
def parse_env(path: Path):
lines = path.read_text(encoding="utf-8").splitlines()
values = {}
for line in lines:
if line and not line.lstrip().startswith("#") and "=" in line:
key, value = line.split("=", 1)
values[key.strip()] = value
return lines, values
def prompt(label: str, description: str, example: str, secret: bool, configured: bool) -> str:
suffix = "(已配置,直接回车保留)" if configured else "(必填)"
print(f"\n{label}\n用途:{description}\n格式示例:{example}")
if secret:
return getpass.getpass(f"{label}{suffix}: ").strip()
return input(f"{label}{suffix}: ").strip()
def write_env(path: Path, lines, values):
rendered = []
written = set()
for line in lines:
if line and not line.lstrip().startswith("#") and "=" in line:
key = line.split("=", 1)[0].strip()
if key in values:
rendered.append(f"{key}={values[key]}")
written.add(key)
continue
rendered.append(line)
for key, value in values.items():
if key not in written:
rendered.append(f"{key}={value}")
temporary = path.with_name(path.name + ".new")
temporary.write_text("\n".join(rendered) + "\n", encoding="utf-8")
os.chmod(temporary, 0o600)
os.replace(temporary, path)
def main():
parser = argparse.ArgumentParser(description="配置 DobotAI Docker 应用连接")
parser.add_argument("--env-file", required=True)
parser.add_argument("--start-script", required=True)
args = parser.parse_args()
env_file = Path(args.env_file)
if not env_file.is_file():
raise SystemExit("DobotAI 环境文件不存在,请先完成 Docker 安装。")
lines, values = parse_env(env_file)
for key, label, description, example, secret in FIELDS:
entered = prompt(label, description, example, secret, bool(values.get(key, "").strip()))
if entered:
values[key] = entered
missing = [label for key, label, _, _, _ in FIELDS if not values.get(key, "").strip()]
if missing:
raise SystemExit("以下必填项未配置:" + "、".join(missing))
write_env(env_file, lines, values)
os.execv(args.start_script, [args.start_script])
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(130)
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
if [[ "$(docker inspect --format '{{.State.Running}}' dobotai-dingtalk 2>/dev/null || true)" != "true" ]]; then
echo "DobotAI 容器未运行。请先执行:sudo dobotai-dingtalk-configure" >&2
exit 1
fi
exec docker exec -it dobotai-dingtalk /usr/local/bin/dobotai bootstrap-admin
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
exec python3 /opt/dobotai-test/container/configure_env.py \
--env-file /etc/dobotai-test/dobotai.env \
--start-script /usr/local/sbin/dobotai-dingtalk-start
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
compose_file="$container_dir/compose.yaml"
runtime_env="$container_dir/runtime.env"
health_url="http://127.0.0.1:8090/healthz"
if [[ ! -r "$compose_file" || ! -r "$runtime_env" ]]; then
echo "Docker 运行文件未安装。请先执行 install-docker.sh。" >&2
exit 1
fi
compose=(docker compose --env-file "$runtime_env" -f "$compose_file")
previous_image="$(docker inspect --format '{{.Config.Image}}' dobotai-dingtalk 2>/dev/null || true)"
set_runtime_image() {
local image="$1"
python3 - "$runtime_env" "$image" <<'PY'
from pathlib import Path
import os
import sys
path = Path(sys.argv[1])
image = sys.argv[2]
lines = path.read_text(encoding="utf-8").splitlines()
rendered = ["DOBOTAI_IMAGE=" + image if line.startswith("DOBOTAI_IMAGE=") else line for line in lines]
if not any(line.startswith("DOBOTAI_IMAGE=") for line in lines):
rendered.append("DOBOTAI_IMAGE=" + image)
temporary = path.with_name(path.name + ".new")
temporary.write_text("\n".join(rendered) + "\n", encoding="utf-8")
os.chmod(temporary, 0o600)
os.replace(temporary, path)
PY
}
rollback() {
"${compose[@]}" rm -sf dobotai-dingtalk >/dev/null 2>&1 || true
if [[ -n "$previous_image" ]]; then
set_runtime_image "$previous_image"
"${compose[@]}" up -d --no-build --force-recreate dobotai-dingtalk >/dev/null 2>&1 || true
fi
}
trap rollback ERR
"${compose[@]}" up -d --no-build dobotai-postgres
"${compose[@]}" up -d --no-build --force-recreate dobotai-dingtalk
for _ in $(seq 1 30); do
if curl --noproxy '*' --fail --silent --max-time 2 "$health_url" >/dev/null; then
trap - ERR
echo "DobotAI 容器已启动:dobotai-dingtalk"
exit 0
fi
sleep 1
done
echo "DobotAI 容器健康检查失败,已回滚到上一容器镜像(如存在)。" >&2
exit 1
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source_root="${DOBOTAI_SOURCE_ROOT:-$(cd "$script_dir/../.." && pwd)}"
home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
config_dir="$home/config"
secret_dir="$home/secrets"
backup_dir="$home/backups"
app_env="$config_dir/dobotai.env"
postgres_env="$config_dir/postgres.env"
runtime_env="$container_dir/runtime.env"
image="${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE to a reviewed DobotAI image tag}"
postgres_image="${DOBOTAI_POSTGRES_IMAGE:-postgres:16-alpine}"
bin_dir="${DOBOTAI_BIN_DIR:-/usr/local/sbin}"
if [[ ${EUID} -ne 0 ]]; then
echo "请使用 sudo 执行 Docker 安装脚本。" >&2
exit 1
fi
command -v docker >/dev/null || { echo "Docker 未安装。" >&2; exit 1; }
docker compose version >/dev/null || { echo "Docker Compose 未安装。" >&2; exit 1; }
docker image inspect "$image" >/dev/null 2>&1 || { echo "候选应用镜像不存在:$image" >&2; exit 1; }
install -d -m 0750 "$home" "$container_dir" "$config_dir" "$backup_dir"
install -d -m 0700 -o 10001 -g 10001 "$secret_dir"
install -d -m 0750 "$container_dir/migrations"
install -m 0644 "$script_dir/compose.yaml" "$container_dir/compose.yaml"
install -m 0700 "$script_dir/configure_env.py" "$container_dir/configure_env.py"
install -m 0750 "$script_dir/dobotai-dingtalk-start" "$container_dir/dobotai-dingtalk-start"
install -m 0750 "$script_dir/dobotai-dingtalk-bind-admin" "$container_dir/dobotai-dingtalk-bind-admin"
install -m 0750 "$script_dir/apply-migrations.sh" "$container_dir/apply-migrations.sh"
install -m 0750 "$script_dir/migrate-existing-database.sh" "$container_dir/migrate-existing-database.sh"
install -m 0644 "$source_root"/db/migrations/*.up.sql "$container_dir/migrations/"
install -m 0644 "$source_root"/db/migrations/*.down.sql "$container_dir/migrations/"
if [[ ! -f "$postgres_env" || ! -f "$app_env" ]]; then
umask 077
python3 - "$postgres_env" "$app_env" <<'PY'
from pathlib import Path
import os
import secrets
import sys
postgres_env = Path(sys.argv[1])
app_env = Path(sys.argv[2])
password = secrets.token_urlsafe(32)
ingress = secrets.token_urlsafe(32)
admin = secrets.token_urlsafe(32)
postgres_env.write_text(
"POSTGRES_DB=dobotai\n"
"POSTGRES_USER=dobotai\n"
f"POSTGRES_PASSWORD={password}\n",
encoding="utf-8",
)
os.chmod(postgres_env, 0o600)
app_env.write_text(
"DOBOTAI_LISTEN_ADDR=0.0.0.0:8090\n"
f"DATABASE_URL=postgres://dobotai:{password}@dobotai-postgres:5432/dobotai?sslmode=disable\n"
f"DOBOTAI_INGRESS_TOKEN={ingress}\n"
f"DOBOTAI_ADMIN_TOKEN={admin}\n"
"DOBOTAI_GROUP_SESSION_POLICY=shared\n"
"DOBOTAI_TENANT_KEY=dobotai\n"
"DOBOTAI_AGENT_BINDINGS=\n"
"DOBOTAI_QUICK_AGENT_ID=builtin-quick-answer\n"
"DOBOTAI_REASONING_AGENT_ID=builtin-smart-reasoning\n"
"WEKNORA_BASE_URL=http://host.docker.internal:8080\n"
"WEKNORA_API_KEY=\n"
"DINGTALK_CLIENT_ID=\n"
"DINGTALK_CLIENT_SECRET=\n",
encoding="utf-8",
)
os.chmod(app_env, 0o600)
PY
fi
umask 077
cat > "$runtime_env" <<EOF
DOBOTAI_IMAGE=$image
DOBOTAI_POSTGRES_IMAGE=$postgres_image
DOBOTAI_ENV_FILE=$app_env
DOBOTAI_POSTGRES_ENV_FILE=$postgres_env
DOBOTAI_SECRET_DIR_HOST=$secret_dir
DOBOTAI_MIGRATIONS_DIR=$container_dir/migrations
EOF
chmod 0600 "$runtime_env"
install -d -m 0755 "$bin_dir"
cat > "$bin_dir/dobotai-dingtalk-configure" <<EOF
#!/usr/bin/env bash
set -euo pipefail
export DOBOTAI_HOME='$home'
exec python3 '$container_dir/configure_env.py' --env-file '$app_env' --start-script '$bin_dir/dobotai-dingtalk-start'
EOF
cat > "$bin_dir/dobotai-dingtalk-start" <<EOF
#!/usr/bin/env bash
set -euo pipefail
export DOBOTAI_HOME='$home'
exec '$container_dir/dobotai-dingtalk-start'
EOF
cat > "$bin_dir/dobotai-dingtalk-bind-admin" <<EOF
#!/usr/bin/env bash
set -euo pipefail
export DOBOTAI_HOME='$home'
exec '$container_dir/dobotai-dingtalk-bind-admin'
EOF
chmod 0750 "$bin_dir/dobotai-dingtalk-configure" "$bin_dir/dobotai-dingtalk-start" "$bin_dir/dobotai-dingtalk-bind-admin"
compose=(docker compose --env-file "$runtime_env" -f "$container_dir/compose.yaml")
"${compose[@]}" up -d dobotai-postgres
for _ in $(seq 1 30); do
status=$(docker inspect -f '{{.State.Health.Status}}' dobotai-postgres 2>/dev/null || true)
[[ "$status" == "healthy" ]] && break
sleep 1
done
[[ "${status:-}" == "healthy" ]] || { echo "PostgreSQL 初始化失败。" >&2; exit 1; }
DOBOTAI_HOME="$home" "$container_dir/apply-migrations.sh"
echo "Docker 基础设施已准备完成:数据库与迁移已就绪,应用尚未启动。"
echo "下一步执行:sudo dobotai-dingtalk-configure"
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$script_dir/install-docker.sh" "$@"
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
set -euo pipefail
home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
config_dir="$home/config"
runtime_env="$container_dir/runtime.env"
compose_file="$container_dir/compose.yaml"
source_container="${SOURCE_POSTGRES_CONTAINER:-dobotai-test-postgres}"
source_app_container="${SOURCE_APP_CONTAINER:-dobotai-dingtalk}"
source_env="${SOURCE_DOBOTAI_ENV:-/etc/dobotai-test/dobotai.env}"
app_env="$config_dir/dobotai.env"
backup_dir="$home/backups"
stamp="$(date -u '+%Y%m%dT%H%M%SZ')"
legacy_app="${source_app_container}-legacy-${stamp}"
[[ ${EUID} -eq 0 ]] || { echo "请使用 sudo 执行现有数据库迁移脚本。" >&2; exit 1; }
[[ -r "$runtime_env" && -r "$compose_file" && -r "$source_env" ]] || { echo "运行文件或旧环境文件不存在。请先执行 install-docker.sh。" >&2; exit 1; }
docker inspect "$source_container" >/dev/null 2>&1 || { echo "源 PostgreSQL 容器不存在:$source_container" >&2; exit 1; }
docker inspect "$source_app_container" >/dev/null 2>&1 || { echo "源 DobotAI 容器不存在:$source_app_container" >&2; exit 1; }
source_pg_user=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$source_container" | python3 -c "import sys; print(next((x.strip().split('=',1)[1] for x in sys.stdin if x.startswith('POSTGRES_USER=')), ''))")
source_pg_db=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$source_container" | python3 -c "import sys; print(next((x.strip().split('=',1)[1] for x in sys.stdin if x.startswith('POSTGRES_DB=')), ''))")
test -n "$source_pg_user" && test -n "$source_pg_db"
install -d -m 0700 "$backup_dir"
dump="$backup_dir/pre-compose-adoption-${stamp}.dump"
compose=(docker compose --env-file "$runtime_env" -f "$compose_file")
"${compose[@]}" up -d dobotai-postgres
for _ in $(seq 1 30); do
status=$(docker inspect -f '{{.State.Health.Status}}' dobotai-postgres 2>/dev/null || true)
[[ "$status" == "healthy" ]] && break
sleep 1
done
[[ "${status:-}" == "healthy" ]] || { echo "目标 PostgreSQL 未健康。" >&2; exit 1; }
old_running=$(docker inspect -f '{{.State.Running}}' "$source_app_container")
rollback() {
docker compose --env-file "$runtime_env" -f "$compose_file" rm -sf dobotai-dingtalk >/dev/null 2>&1 || true
if docker inspect "$legacy_app" >/dev/null 2>&1; then
docker rename "$legacy_app" "$source_app_container" || true
[[ "$old_running" == "true" ]] && docker start "$source_app_container" >/dev/null 2>&1 || true
fi
}
trap rollback ERR
[[ "$old_running" == "true" ]] && docker stop "$source_app_container" >/dev/null
docker rename "$source_app_container" "$legacy_app"
docker exec -e PGUSER="$source_pg_user" "$source_container" pg_dump -Fc --no-owner -d "$source_pg_db" > "$dump"
test -s "$dump"
docker exec -i dobotai-postgres pg_restore --list < "$dump" >/dev/null
sha256sum "$dump" > "$dump.sha256"
# Replace only deployment-managed endpoints; preserve all existing application credentials.
python3 - "$source_env" "$app_env" "$config_dir/postgres.env" <<'PY'
from pathlib import Path
import os
import sys
source, target, postgres_env = map(Path, sys.argv[1:])
password = next(line.split('=', 1)[1].strip() for line in postgres_env.read_text().splitlines() if line.startswith('POSTGRES_PASSWORD='))
values = {}
for line in source.read_text().splitlines():
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
values[key] = value
values['DOBOTAI_LISTEN_ADDR'] = '0.0.0.0:8090'
values['DATABASE_URL'] = f'postgres://dobotai:{password}@dobotai-postgres:5432/dobotai?sslmode=disable'
values['WEKNORA_BASE_URL'] = 'http://host.docker.internal:8080'
temporary = target.with_name(target.name + '.new')
temporary.write_text('\n'.join(f'{k}={v}' for k, v in values.items()) + '\n', encoding='utf-8')
os.chmod(temporary, 0o600)
os.replace(temporary, target)
PY
"${compose[@]}" exec -T dobotai-postgres psql -v ON_ERROR_STOP=1 -U dobotai -d dobotai -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
docker exec -i dobotai-postgres pg_restore -U dobotai -d dobotai --no-owner < "$dump"
DOBOTAI_HOME="$home" "$container_dir/apply-migrations.sh"
"${compose[@]}" up -d --no-build dobotai-dingtalk
for _ in $(seq 1 30); do
if curl --noproxy '*' --fail --silent --max-time 2 http://127.0.0.1:8090/healthz >/dev/null; then
trap - ERR
echo "现有DobotAI数据库已迁移到 dobotai-postgres-data;旧应用容器保留为 $legacy_app。"
exit 0
fi
sleep 1
done
echo "迁移后的应用健康检查失败,正在恢复旧容器。" >&2
exit 1
@@ -0,0 +1,7 @@
# 非敏感运行参数;由 install-docker.sh 生成实际文件。
DOBOTAI_IMAGE=dobotai-dingtalk:test
DOBOTAI_POSTGRES_IMAGE=postgres:16-alpine
DOBOTAI_ENV_FILE=/opt/dobotai/config/dobotai.env
DOBOTAI_POSTGRES_ENV_FILE=/opt/dobotai/config/postgres.env
DOBOTAI_SECRET_DIR_HOST=/opt/dobotai/secrets
DOBOTAI_MIGRATIONS_DIR=/opt/dobotai/container/migrations
+16
View File
@@ -37,6 +37,22 @@ Agent 由配置控制,渠道调用方不得任意指定 `agent_id`。
2. 渠道通配 `<channel>:*`;
3. 没有任何绑定时失败关闭。
## 首位管理员终端绑定
首位管理员绑定由部署服务器终端发起,而非网页发码。钉钉 Stream 机器人已正常运行且当前不存在有效管理员时,操作人员执行:
```bash
dobotai bootstrap-admin
```
命令只连接 DobotAI PostgreSQL,使用加密随机数创建一个有效期10分钟、最多5次错误尝试的六码挑战,并在终端持续等待。候选用户必须私聊机器人发送:
```text
/setting <六码绑定码>
```
验证码匹配后,渠道侧只记录候选身份;终端轮询并事务性将该候选用户写为 `super_admin/active`,然后显示成功并退出。已有有效管理员时该命令拒绝执行,避免任意用户通过终端绑定流程被提权。DobotAI 不提供网页管理面,钉钉凭据和运行配置由部署环境文件管理。
## 图片失败策略
启用图片流量前,WeKnora Adapter 必须针对选定的官方版本和视觉模型执行契约测试。
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
# 唯一安装入口:镜像仓库名与标签策略只在本脚本维护。
postgres_image="postgres:16-alpine"
if [[ ${EUID} -ne 0 ]]; then
echo "请在克隆仓库根目录执行:sudo bash ./install-docker.sh" >&2
exit 1
fi
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$repo_root"
[[ -f Dockerfile && -f deploy/dobotai-dingtalk/install-docker.sh ]] || {
echo "安装脚本必须从完整DobotAI源码仓库根目录运行。" >&2
exit 1
}
command -v git >/dev/null || { echo "未找到 git,无法确定当前源码版本。" >&2; exit 1; }
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null || {
echo "请从 git clone 得到的仓库中执行本脚本。" >&2
exit 1
}
install_docker_if_missing() {
if command -v docker >/dev/null && docker compose version >/dev/null 2>&1; then
return
fi
command -v apt-get >/dev/null || {
echo "本脚本仅自动安装基于 apt 的 Docker;请先安装 Docker Engine 和 Docker Compose v2。" >&2
exit 1
}
export DEBIAN_FRONTEND=noninteractive
apt-get update
if apt-cache show docker-compose-v2 >/dev/null 2>&1; then
apt-get install -y docker.io docker-compose-v2
elif apt-cache show docker-compose-plugin >/dev/null 2>&1; then
apt-get install -y docker.io docker-compose-plugin
else
echo "当前APT源没有 Docker Compose v2 插件,无法安全继续。" >&2
exit 1
fi
systemctl enable --now docker
docker compose version >/dev/null
}
install_docker_if_missing
commit_sha="$(git -C "$repo_root" rev-parse HEAD)"
commit_short="$(git -C "$repo_root" rev-parse --short=12 HEAD)"
build_time="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
image="dobotai-dingtalk:local-${commit_short}"
echo "[1/3] 构建 DobotAI 镜像:$image"
docker build \
--build-arg "GOPROXY=https://goproxy.cn,direct" \
--build-arg "VERSION=local-${commit_short}" \
--build-arg "COMMIT_SHA=${commit_sha}" \
--build-arg "BUILD_TIME=${build_time}" \
--label "org.opencontainers.image.revision=${commit_sha}" \
--tag "$image" \
"$repo_root"
echo "[2/3] 准备独立PostgreSQL、目录、配置文件和迁移"
export DOBOTAI_IMAGE="$image"
export DOBOTAI_POSTGRES_IMAGE="$postgres_image"
export DOBOTAI_SOURCE_ROOT="$repo_root"
exec bash "$repo_root/deploy/dobotai-dingtalk/install-docker.sh"
-205
View File
@@ -1,205 +0,0 @@
package adminweb
import (
"context"
"errors"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type ConfigRepository interface {
ActiveDingTalkConfig(context.Context, string) (string, string, bool, error)
CandidateDingTalkConfig(context.Context, string) (string, string, error)
SaveDingTalkConfig(context.Context, string, string, string, bool) error
MarkDingTalkCandidateFailed(context.Context, string, string, string) error
AdministratorCount(context.Context, string) (int, error)
ActiveBindings(context.Context, string) (map[string]settings.ResourceBinding, error)
CreateBootstrapChallenge(context.Context, string, string, time.Time) error
ConfirmBootstrap(context.Context, string) error
}
type AdministratorRepository interface {
ListAdministrators(context.Context, string) ([]settings.Administrator, error)
DisableAdministrator(context.Context, string, string) error
}
type CandidateActivator interface{ ActivateCandidate(context.Context) error }
type OperationalStatusProvider interface {
Components(context.Context) ([]Component, error)
}
type CredentialVerifier interface {
Verify(context.Context, string, string) dingtalkconfig.Result
}
type ControlService struct {
tenant string
repo ConfigRepository
secrets *secretstore.Store
verifier CredentialVerifier
activator CandidateActivator
status OperationalStatusProvider
}
func NewControlService(tenant string, repo ConfigRepository, secrets *secretstore.Store, verifier CredentialVerifier) (*ControlService, error) {
if strings.TrimSpace(tenant) == "" || repo == nil || secrets == nil || verifier == nil {
return nil, errors.New("admin control dependencies are required")
}
return &ControlService{tenant: strings.TrimSpace(tenant), repo: repo, secrets: secrets, verifier: verifier}, nil
}
func (s *ControlService) Snapshot(ctx context.Context) (Snapshot, error) {
id, ref, verified, err := s.repo.ActiveDingTalkConfig(ctx, s.tenant)
if err != nil {
return Snapshot{}, err
}
admins, err := s.repo.AdministratorCount(ctx, s.tenant)
if err != nil {
return Snapshot{}, err
}
bindings, err := s.repo.ActiveBindings(ctx, s.tenant)
if err != nil {
return Snapshot{}, err
}
administratorViews := []AdministratorView{}
if administratorRepo, ok := s.repo.(AdministratorRepository); ok {
administrators, listErr := administratorRepo.ListAdministrators(ctx, s.tenant)
if listErr != nil {
return Snapshot{}, listErr
}
activeCount := 0
for _, administrator := range administrators {
if administrator.Status == "active" {
activeCount++
}
}
for _, administrator := range administrators {
administratorViews = append(administratorViews, AdministratorView{
StaffID: administrator.StaffID, StaffIDMasked: maskID(administrator.StaffID),
DisplayName: administrator.DisplayName, Department: administrator.Department,
Role: administrator.Role, Status: administrator.Status,
CanDisable: administrator.Status == "active" && activeCount > 1,
})
}
}
state := settings.DetermineState(settings.Snapshot{ClientID: id, SecretConfigured: s.secrets.Configured(ref), CredentialsVerified: verified, AdministratorCount: admins})
now := time.Now()
components := []Component{
{Name: "配置仓库", State: "ok", Code: "repository_ok", Detail: "PostgreSQL配置查询成功", LastSuccess: now, CheckedAt: now},
{Name: "钉钉OAuth", State: status(verified), Code: ternaryCode(verified, "oauth_verified", "oauth_unconfigured"), Detail: "当前应用凭据验证状态", CheckedAt: now},
{Name: "管理员", State: countStatus(admins), Code: ternaryCode(admins > 0, "admin_configured", "admin_missing"), Detail: itoa(admins) + " 位有效管理员", CheckedAt: now},
{Name: "技术支持群绑定", State: bindingStatus(bindings, "support_group"), Code: bindingCode(bindings, "support_group"), Detail: "仅显示是否存在active绑定", CheckedAt: now},
{Name: "问题AI表格绑定", State: bindingStatus(bindings, "question_table"), Code: bindingCode(bindings, "question_table"), Detail: "仅显示是否存在active绑定", CheckedAt: now},
{Name: "FAQ表格绑定", State: bindingStatus(bindings, "faq_table"), Code: bindingCode(bindings, "faq_table"), Detail: "仅显示是否存在active绑定", CheckedAt: now},
}
if s.status != nil {
operational, probeErr := s.status.Components(ctx)
if probeErr != nil {
components = append(components, Component{Name: "运行探针", State: "error", Code: "component_probe_failed", Detail: "运行状态探针失败;未显示原始错误", CheckedAt: time.Now()})
} else {
components = append(components, operational...)
}
}
return Snapshot{SystemState: string(state), ClientIDMasked: maskID(id), SecretConfigured: s.secrets.Configured(ref), Components: components, Administrators: administratorViews}, nil
}
func (s *ControlService) VerifyCredentials(ctx context.Context, id, secret string) Verification {
r := s.verifier.Verify(ctx, id, secret)
return Verification{OK: r.OK, Code: r.Code, CheckedAt: r.CheckedAt}
}
func (s *ControlService) SaveCredentials(ctx context.Context, id, secret string) error {
_, oldRef, _ := s.repo.CandidateDingTalkConfig(ctx, s.tenant)
ref, err := s.secrets.Put("dingtalk", secret)
if err != nil {
return err
}
if err = s.repo.SaveDingTalkConfig(ctx, s.tenant, strings.TrimSpace(id), ref, false); err != nil {
_ = s.secrets.Delete(ref)
return err
}
if oldRef != "" && oldRef != ref {
_ = s.secrets.Delete(oldRef)
}
return nil
}
func (s *ControlService) SetCandidateActivator(activator CandidateActivator) { s.activator = activator }
func (s *ControlService) SetOperationalStatusProvider(provider OperationalStatusProvider) {
s.status = provider
}
func (s *ControlService) ActivateCandidate(ctx context.Context) error {
if s.activator == nil {
return errors.New("candidate activator unavailable")
}
_, ref, err := s.repo.CandidateDingTalkConfig(ctx, s.tenant)
if err != nil {
return err
}
if err = s.activator.ActivateCandidate(ctx); err != nil {
_ = s.repo.MarkDingTalkCandidateFailed(ctx, s.tenant, ref, "activation_failed")
return errors.New("candidate activation failed")
}
return nil
}
func (s *ControlService) CreateBootstrap(ctx context.Context, code string) error {
return s.repo.CreateBootstrapChallenge(ctx, s.tenant, code, time.Now().Add(10*time.Minute))
}
func (s *ControlService) ConfirmBootstrap(ctx context.Context) error {
return s.repo.ConfirmBootstrap(ctx, s.tenant)
}
func (s *ControlService) DisableAdministrator(ctx context.Context, staffID string) error {
staffID = strings.TrimSpace(staffID)
if staffID == "" {
return settings.ErrAdministratorNotFound
}
repo, ok := s.repo.(AdministratorRepository)
if !ok {
return errors.New("administrator management unavailable")
}
return repo.DisableAdministrator(ctx, s.tenant, staffID)
}
func maskID(v string) string {
v = strings.TrimSpace(v)
if len(v) <= 6 {
return v
}
return v[:3] + "***" + v[len(v)-3:]
}
func status(ok bool) string {
if ok {
return "ok"
}
return "unconfigured"
}
func countStatus(n int) string {
if n > 0 {
return "ok"
}
return "unconfigured"
}
func bindingStatus(m map[string]settings.ResourceBinding, k string) string {
if _, ok := m[k]; ok {
return "configured"
}
return "unconfigured"
}
func bindingCode(m map[string]settings.ResourceBinding, k string) string {
if _, ok := m[k]; ok {
return "binding_active"
}
return "binding_missing"
}
func ternaryCode(ok bool, success, failure string) string {
if ok {
return success
}
return failure
}
func itoa(n int) string {
if n == 0 {
return "0"
}
b := make([]byte, 0, 8)
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
-210
View File
@@ -1,210 +0,0 @@
package adminweb
import (
"context"
"errors"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type controlRepo struct {
savedVerified bool
savedRef string
candidateRef string
failedCode string
admins []settings.Administrator
disabledStaff string
disableErr error
}
func (*controlRepo) ActiveDingTalkConfig(context.Context, string) (string, string, bool, error) {
return "old", "old-ref", true, nil
}
func (r *controlRepo) CandidateDingTalkConfig(context.Context, string) (string, string, error) {
return "new", r.candidateRef, nil
}
func (r *controlRepo) SaveDingTalkConfig(_ context.Context, _, _, ref string, verified bool) error {
r.savedRef = ref
r.savedVerified = verified
r.candidateRef = ref
return nil
}
func (r *controlRepo) MarkDingTalkCandidateFailed(_ context.Context, _, _, code string) error {
r.failedCode = code
return nil
}
func (r *controlRepo) AdministratorCount(context.Context, string) (int, error) {
if r.admins == nil {
return 1, nil
}
n := 0
for _, admin := range r.admins {
if admin.Status == "active" {
n++
}
}
return n, nil
}
func (r *controlRepo) ListAdministrators(context.Context, string) ([]settings.Administrator, error) {
return r.admins, nil
}
func (r *controlRepo) DisableAdministrator(_ context.Context, _, staffID string) error {
r.disabledStaff = staffID
return r.disableErr
}
func (*controlRepo) ActiveBindings(context.Context, string) (map[string]settings.ResourceBinding, error) {
return map[string]settings.ResourceBinding{}, nil
}
func (*controlRepo) CreateBootstrapChallenge(context.Context, string, string, time.Time) error {
return nil
}
func (*controlRepo) ConfirmBootstrap(context.Context, string) error { return nil }
type verifyOK struct{}
func (verifyOK) Verify(context.Context, string, string) dingtalkconfig.Result {
return dingtalkconfig.Result{OK: true, Code: "oauth_ok"}
}
type activatorStub struct {
err error
calls int
}
func (a *activatorStub) ActivateCandidate(context.Context) error { a.calls++; return a.err }
type operationalStatusStub struct {
components []Component
err error
}
func (s operationalStatusStub) Components(context.Context) ([]Component, error) {
return s.components, s.err
}
func TestSnapshotIncludesOperationalComponents(t *testing.T) {
secrets, err := secretstore.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
service, err := NewControlService("tenant", &controlRepo{}, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
checked := time.Unix(100, 0)
service.SetOperationalStatusProvider(operationalStatusStub{components: []Component{{
Name: "WeKnora", State: "ok", Code: "health_ok", CheckedAt: checked,
}}})
snapshot, err := service.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
found := false
for _, component := range snapshot.Components {
if component.Name == "WeKnora" && component.Code == "health_ok" && component.CheckedAt.Equal(checked) {
found = true
}
}
if !found {
t.Fatalf("components=%+v", snapshot.Components)
}
}
func TestSnapshotKeepsPageAvailableWhenOperationalProbeFails(t *testing.T) {
secrets, _ := secretstore.New(t.TempDir())
service, err := NewControlService("tenant", &controlRepo{}, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
service.SetOperationalStatusProvider(operationalStatusStub{err: errors.New("probe failed")})
snapshot, err := service.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
last := snapshot.Components[len(snapshot.Components)-1]
if last.Name != "运行探针" || last.State != "error" || last.Code != "component_probe_failed" {
t.Fatalf("component=%+v", last)
}
}
func TestSaveCredentialsCreatesCandidateNotActive(t *testing.T) {
secrets, err := secretstore.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
repo := &controlRepo{}
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
if err = service.SaveCredentials(context.Background(), "new", "secret"); err != nil {
t.Fatal(err)
}
if repo.savedVerified {
t.Fatal("candidate must not be active")
}
if repo.savedRef == "" || !secrets.Configured(repo.savedRef) {
t.Fatalf("ref=%q", repo.savedRef)
}
}
func TestActivateCandidateDelegatesAndMarksFailure(t *testing.T) {
secrets, err := secretstore.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
repo := &controlRepo{candidateRef: "candidate-ref"}
activator := &activatorStub{err: errors.New("candidate_start_failed_rolled_back")}
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
service.SetCandidateActivator(activator)
if err = service.ActivateCandidate(context.Background()); err == nil {
t.Fatal("expected failure")
}
if activator.calls != 1 || repo.failedCode != "activation_failed" {
t.Fatalf("calls=%d code=%q", activator.calls, repo.failedCode)
}
}
func TestSnapshotIncludesAdministratorsAndOnlyAllowsDisableWhenAnotherIsActive(t *testing.T) {
secrets, _ := secretstore.New(t.TempDir())
repo := &controlRepo{admins: []settings.Administrator{
{StaffID: "staff-old", DisplayName: "旧管理员", Department: "技术部", Role: "super_admin", Status: "active"},
{StaffID: "staff-new", DisplayName: "新管理员", Department: "产品部", Role: "super_admin", Status: "active"},
{StaffID: "staff-off", DisplayName: "停用管理员", Role: "admin", Status: "disabled"},
}}
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
snapshot, err := service.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
if len(snapshot.Administrators) != 3 || !snapshot.Administrators[0].CanDisable || !snapshot.Administrators[1].CanDisable || snapshot.Administrators[2].CanDisable {
t.Fatalf("administrators=%+v", snapshot.Administrators)
}
if snapshot.Administrators[0].StaffIDMasked == "staff-old" || snapshot.Administrators[0].StaffIDMasked == "" {
t.Fatalf("staff ID must be masked: %+v", snapshot.Administrators[0])
}
}
func TestControlServiceDelegatesAdministratorDisable(t *testing.T) {
secrets, _ := secretstore.New(t.TempDir())
repo := &controlRepo{}
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
if err = service.DisableAdministrator(context.Background(), "staff-old"); err != nil {
t.Fatal(err)
}
if repo.disabledStaff != "staff-old" {
t.Fatalf("disabled=%q", repo.disabledStaff)
}
}
-253
View File
@@ -1,253 +0,0 @@
package adminweb
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"html/template"
"net/http"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type Snapshot struct {
SystemState, ClientIDMasked string
SecretConfigured bool
Components []Component
Administrators []AdministratorView
}
type AdministratorView struct {
StaffID, StaffIDMasked, DisplayName, Department, Role, Status string
CanDisable bool
}
type Component struct {
Name, State, Code, Detail string
LastSuccess, CheckedAt time.Time
}
type Verification struct {
OK bool
Code string
CheckedAt time.Time
}
type Service interface {
Snapshot(context.Context) (Snapshot, error)
SaveCredentials(context.Context, string, string) error
VerifyCredentials(context.Context, string, string) Verification
}
type BootstrapService interface {
CreateBootstrap(context.Context, string) error
ConfirmBootstrap(context.Context) error
}
type CandidateService interface {
ActivateCandidate(context.Context) error
}
type AdministratorService interface {
DisableAdministrator(context.Context, string) error
}
type server struct {
service Service
token string
csrfToken string
mux *http.ServeMux
page *template.Template
}
type pageData struct {
Snapshot
CSRFToken string
}
const settingsPage = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>DobotAI 设置</title>
<style>
body{font-family:system-ui,sans-serif;background:#f5f8ff;color:#17324d;max-width:1080px;margin:36px auto;padding:0 20px;line-height:1.55}
.card{background:#fff;border:1px solid #dbe7f4;border-radius:14px;padding:20px;margin:14px 0;box-shadow:0 4px 16px rgba(32,77,120,.05)}
input{box-sizing:border-box;padding:10px;width:100%;margin:6px 0 12px;border:1px solid #bfd0e2;border-radius:8px}
button{padding:10px 18px;background:#1677ff;color:#fff;border:0;border-radius:8px;margin:5px 0;cursor:pointer}.danger{background:#c73535}.muted{color:#55708a}.warn{background:#fff7e6;border-left:4px solid #f0a020;padding:10px 12px;border-radius:6px}.meta{color:#55708a;font-size:13px;line-height:1.6}
table{width:100%;border-collapse:collapse;margin-top:12px}th,td{text-align:left;padding:10px;border-bottom:1px solid #e4edf6;vertical-align:middle}.inline{margin:0}.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}@media(max-width:760px){.grid{grid-template-columns:1fr}table{font-size:13px}}
</style>
</head>
<body>
<h1>DobotAI 配置中心</h1>
<div class="card"><b>系统状态:</b>{{.SystemState}}<br><b>Client ID:</b>{{.ClientIDMasked}}<br><b>Client Secret:</b>{{if .SecretConfigured}}已配置(不可查看){{else}}未配置{{end}}</div>
<div class="card"><h2>配置/切换钉钉应用</h2><p>新凭据先在线验证并保存为候选。激活后受控重启整组钉钉组件,失败自动回滚。</p><form method="post" action="/admin/setup/credentials"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Client ID / AppKey</label><input name="client_id" required><label>Client Secret / AppSecret</label><input type="password" name="client_secret" required autocomplete="new-password"><button>验证并保存候选</button></form><form method="post" action="/admin/setup/candidate/activate"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button>激活候选并重启</button></form></div>
<div class="card"><h2>新增/更换管理员</h2><p class="warn"><b>必须先加后停:</b>先确认新管理员已生效,再停用旧管理员。系统禁止停用最后一位有效管理员。</p><ol><li>输入任意6位一次性绑定码并生成,有效期10分钟。</li><li>新管理员私聊机器人发送 <code>/setting 6位码</code>。</li><li>回到本页确认检测到的新管理员。</li><li>在下方确认新管理员状态为active,再停用旧管理员。</li></ol><div class="grid"><form method="post" action="/admin/setup/bootstrap"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>一次性绑定码</label><input name="code" pattern="[0-9]{6}" placeholder="输入任意6位数字" required><button>生成6位绑定码</button></form><form method="post" action="/admin/setup/bootstrap/confirm"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><p class="muted">新管理员私聊验证后再点击:</p><button>确认检测到的新管理员</button></form></div><h3>管理员列表</h3><table><thead><tr><th>姓名</th><th>部门</th><th>Staff ID</th><th>角色</th><th>状态</th><th>操作</th></tr></thead><tbody>{{range .Administrators}}<tr><td>{{if .DisplayName}}{{.DisplayName}}{{else}}—{{end}}</td><td>{{if .Department}}{{.Department}}{{else}}—{{end}}</td><td>{{.StaffIDMasked}}</td><td>{{.Role}}</td><td>{{.Status}}</td><td>{{if .CanDisable}}<form class="inline" method="post" action="/admin/settings/administrators/disable"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="staff_id" value="{{.StaffID}}"><button class="danger" type="submit">停用此管理员</button></form>{{else}}—{{end}}</td></tr>{{else}}<tr><td colspan="6" class="muted">暂无管理员记录</td></tr>{{end}}</tbody></table></div>
<h2>组件检查</h2>{{range .Components}}<div class="card"><b>{{.Name}}</b>:{{.State}}<div class="meta">错误码:{{if .Code}}{{.Code}}{{else}}—{{end}}<br>最后成功:{{timeText .LastSuccess}}<br>最后检查:{{timeText .CheckedAt}}</div><p>{{.Detail}}</p></div>{{end}}
</body></html>`
func New(service Service, token string) http.Handler {
functions := template.FuncMap{"timeText": func(value time.Time) string {
if value.IsZero() {
return "—"
}
return value.UTC().Format("2006-01-02 15:04:05")
}}
csrfBytes := make([]byte, 32)
if _, err := rand.Read(csrfBytes); err != nil {
panic("admin CSRF token unavailable")
}
s := &server{service: service, token: strings.TrimSpace(token), csrfToken: hex.EncodeToString(csrfBytes), mux: http.NewServeMux(), page: template.Must(template.New("settings").Funcs(functions).Parse(settingsPage))}
s.mux.HandleFunc("GET /admin/settings", s.settings)
s.mux.HandleFunc("GET /admin/setup", s.settings)
s.mux.HandleFunc("POST /admin/setup/credentials", s.saveCredentials)
s.mux.HandleFunc("POST /admin/setup/candidate/activate", s.activateCandidate)
s.mux.HandleFunc("POST /admin/setup/bootstrap", s.createBootstrap)
s.mux.HandleFunc("POST /admin/setup/bootstrap/confirm", s.confirmBootstrap)
s.mux.HandleFunc("POST /admin/settings/administrators/disable", s.disableAdministrator)
return s
}
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if !s.authorized(r) {
w.Header().Set("WWW-Authenticate", `Basic realm="DobotAI Admin"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
s.mux.ServeHTTP(w, r)
}
func (s *server) authorized(r *http.Request) bool {
if s.token == "" {
return false
}
if _, password, ok := r.BasicAuth(); ok && len(password) == len(s.token) && subtle.ConstantTimeCompare([]byte(password), []byte(s.token)) == 1 {
return true
}
v := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
return len(v) == len(s.token) && subtle.ConstantTimeCompare([]byte(v), []byte(s.token)) == 1
}
func (s *server) requireCSRFForm(w http.ResponseWriter, r *http.Request, maxBytes int64) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return false
}
provided := strings.TrimSpace(r.FormValue("csrf_token"))
if len(provided) != len(s.csrfToken) || subtle.ConstantTimeCompare([]byte(provided), []byte(s.csrfToken)) != 1 {
http.Error(w, "forbidden", http.StatusForbidden)
return false
}
return true
}
func (s *server) settings(w http.ResponseWriter, r *http.Request) {
snap, err := s.service.Snapshot(r.Context())
if err != nil {
http.Error(w, "settings unavailable", 500)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = s.page.Execute(w, pageData{Snapshot: snap, CSRFToken: s.csrfToken})
}
func (s *server) saveCredentials(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 16<<10) {
return
}
id, secret := strings.TrimSpace(r.FormValue("client_id")), strings.TrimSpace(r.FormValue("client_secret"))
result := s.service.VerifyCredentials(r.Context(), id, secret)
if !result.OK {
http.Error(w, fmt.Sprintf("credential verification failed: %s", result.Code), 422)
return
}
if err := s.service.SaveCredentials(r.Context(), id, secret); err != nil {
http.Error(w, "credential save failed", 500)
return
}
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
}
func (s *server) activateCandidate(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 4<<10) {
return
}
service, ok := s.service.(CandidateService)
if !ok {
http.Error(w, "candidate unavailable", http.StatusNotImplemented)
return
}
if err := service.ActivateCandidate(r.Context()); err != nil {
http.Error(w, "candidate activation failed", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("候选已激活,DobotAI正在受控重启。\n"))
}
func (s *server) createBootstrap(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 4<<10) {
return
}
service, ok := s.service.(BootstrapService)
if !ok {
http.Error(w, "bootstrap unavailable", http.StatusNotImplemented)
return
}
code := strings.TrimSpace(r.FormValue("code"))
if !sixDigits(code) {
http.Error(w, "binding code must be 6 digits", 400)
return
}
if err := service.CreateBootstrap(r.Context(), code); err != nil {
http.Error(w, "create binding code failed", 500)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "请在10分钟内私聊机器人发送:/setting %s\n", code)
}
func (s *server) confirmBootstrap(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 4<<10) {
return
}
service, ok := s.service.(BootstrapService)
if !ok {
http.Error(w, "bootstrap unavailable", http.StatusNotImplemented)
return
}
if err := service.ConfirmBootstrap(r.Context()); err != nil {
http.Error(w, "尚未检测到可确认的管理员", http.StatusConflict)
return
}
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
}
func (s *server) disableAdministrator(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 4<<10) {
return
}
service, ok := s.service.(AdministratorService)
if !ok {
http.Error(w, "administrator management unavailable", http.StatusNotImplemented)
return
}
staffID := strings.TrimSpace(r.FormValue("staff_id"))
if staffID == "" {
http.Error(w, "administrator not found", http.StatusNotFound)
return
}
err := service.DisableAdministrator(r.Context(), staffID)
switch {
case err == nil:
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
case errors.Is(err, settings.ErrLastAdministrator):
http.Error(w, "至少保留一位有效管理员;请先确认新管理员已生效。", http.StatusConflict)
case errors.Is(err, settings.ErrAdministratorAlreadyDisabled):
http.Error(w, "管理员已经停用。", http.StatusConflict)
case errors.Is(err, settings.ErrAdministratorNotFound):
http.Error(w, "administrator not found", http.StatusNotFound)
default:
http.Error(w, "administrator update failed", http.StatusInternalServerError)
}
}
func sixDigits(value string) bool {
if len(value) != 6 {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
-166
View File
@@ -1,166 +0,0 @@
package adminweb
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type fakeService struct {
snapshot Snapshot
verified bool
saved bool
disabled string
disableErr error
}
func (f *fakeService) Snapshot(context.Context) (Snapshot, error) { return f.snapshot, nil }
func (f *fakeService) SaveCredentials(context.Context, string, string) error {
f.saved = true
return nil
}
func (f *fakeService) VerifyCredentials(context.Context, string, string) Verification {
f.verified = true
return Verification{OK: true, Code: "ok", CheckedAt: time.Unix(1, 0)}
}
func (f *fakeService) DisableAdministrator(_ context.Context, staffID string) error {
f.disabled = staffID
return f.disableErr
}
func csrfTokenFromPage(t *testing.T, h http.Handler) string {
t.Helper()
get := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
get.Header.Set("Authorization", "Bearer admin-token")
page := httptest.NewRecorder()
h.ServeHTTP(page, get)
marker := `name="csrf_token" value="`
start := strings.Index(page.Body.String(), marker)
if start < 0 {
t.Fatalf("csrf token missing: %s", page.Body.String())
}
start += len(marker)
end := strings.Index(page.Body.String()[start:], `"`)
if end < 0 {
t.Fatal("csrf token terminator missing")
}
return page.Body.String()[start : start+end]
}
func TestDashboardNeverRendersSecrets(t *testing.T) {
svc := &fakeService{snapshot: Snapshot{SystemState: "active", ClientIDMasked: "ding***", SecretConfigured: true}}
h := New(svc, "admin-token")
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("status=%d", w.Code)
}
body := w.Body.String()
if strings.Contains(body, "admin-token") || strings.Contains(strings.ToLower(body), "client secret value") {
t.Fatal("secret leaked")
}
if !strings.Contains(body, "ding***") {
t.Fatal("masked client id missing")
}
}
func TestDashboardRendersOperationalComponentFields(t *testing.T) {
checked := time.Date(2026, 8, 13, 18, 0, 0, 0, time.UTC)
svc := &fakeService{snapshot: Snapshot{Components: []Component{{
Name: "WeKnora", State: "ok", Code: "health_ok", Detail: "HTTP健康探针正常",
LastSuccess: checked.Add(-time.Minute), CheckedAt: checked,
}}}}
h := New(svc, "admin-token")
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
body := w.Body.String()
for _, want := range []string{"WeKnora", "health_ok", "HTTP健康探针正常", "2026-08-13 17:59:00", "2026-08-13 18:00:00"} {
if !strings.Contains(body, want) {
t.Fatalf("dashboard missing %q: %s", want, body)
}
}
}
func TestSaveCredentialsRequiresVerification(t *testing.T) {
svc := &fakeService{}
h := New(svc, "admin-token")
csrf := csrfTokenFromPage(t, h)
req := httptest.NewRequest(http.MethodPost, "/admin/setup/credentials", strings.NewReader("client_id=id&client_secret=secret&csrf_token="+csrf))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther || !svc.verified || !svc.saved {
t.Fatalf("status=%d verified=%v saved=%v", w.Code, svc.verified, svc.saved)
}
}
func TestAllAdminPOSTRoutesRejectMissingCSRF(t *testing.T) {
svc := &fakeService{}
h := New(svc, "admin-token")
for _, route := range []string{"/admin/setup/credentials", "/admin/setup/candidate/activate", "/admin/setup/bootstrap", "/admin/setup/bootstrap/confirm", "/admin/settings/administrators/disable"} {
req := httptest.NewRequest(http.MethodPost, route, strings.NewReader("client_id=id&client_secret=secret&code=123456&staff_id=staff-old"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("route=%s status=%d body=%q", route, w.Code, w.Body.String())
}
}
if svc.saved || svc.disabled != "" {
t.Fatalf("business service called saved=%v disabled=%q", svc.saved, svc.disabled)
}
}
func TestDashboardRendersAdministratorReplacementWorkflow(t *testing.T) {
svc := &fakeService{snapshot: Snapshot{Administrators: []AdministratorView{{StaffID: "staff-old", StaffIDMasked: "sta***old", DisplayName: "旧管理员", Department: "技术部", Role: "super_admin", Status: "active", CanDisable: true}}}}
h := New(svc, "admin-token")
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
for _, want := range []string{"新增/更换管理员", "先加后停", "旧管理员", "技术部", "sta***old", "生成6位绑定码", "停用此管理员"} {
if !strings.Contains(w.Body.String(), want) {
t.Fatalf("dashboard missing %q: %s", want, w.Body.String())
}
}
if strings.Contains(w.Body.String(), ">staff-old<") {
t.Fatalf("raw staff ID must not be rendered as visible text: %s", w.Body.String())
}
}
func TestDisableAdministratorRequiresCSRFAndMapsLastAdminConflict(t *testing.T) {
svc := &fakeService{}
h := New(svc, "admin-token")
post := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/admin/settings/administrators/disable", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
if w := post("staff_id=staff-old"); w.Code != http.StatusForbidden || svc.disabled != "" {
t.Fatalf("missing csrf status=%d disabled=%q", w.Code, svc.disabled)
}
csrf := csrfTokenFromPage(t, h)
svc.disableErr = settings.ErrLastAdministrator
w := post("staff_id=staff-old&csrf_token=" + csrf)
if w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "至少保留一位有效管理员") {
t.Fatalf("last-admin status=%d body=%q", w.Code, w.Body.String())
}
svc.disableErr = nil
w = post("staff_id=staff-old&csrf_token=" + csrf)
if w.Code != http.StatusSeeOther || svc.disabled != "staff-old" {
t.Fatalf("success status=%d disabled=%q body=%q", w.Code, svc.disabled, w.Body.String())
}
}
+19 -2
View File
@@ -60,7 +60,10 @@ func (e *Evaluator) Version() string {
// - no references + knowledge searched → uncovered_no_references
// - no references + no knowledge search → covered_visual_recognition
// (pure visual recognition is not a coverage miss)
// 4. Text turns: zero references → uncovered_no_references
// 4. Text turns with zero references are classified by whether the question
// actually triggered a knowledge-base search:
// - knowledge searched → uncovered_no_references
// - no knowledge search → covered_non_knowledge_response
// 5. Has references → covered_has_references
//
// v1 never uses a score threshold. TopScore captures the highest reference score
@@ -126,8 +129,22 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
}, nil
}
// No references → uncovered.
// Text turns without references are only uncovered if they actually
// triggered knowledge search. This prevents greetings and other normal
// non-knowledge responses from generating artificial support cases.
if len(turn.References) == 0 {
if !turn.KnowledgeSearched {
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictCovered,
ReasonCode: ReasonCoveredNonKnowledgeResponse,
ReferenceCount: 0,
TopScore: 0,
}, nil
}
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
+27 -7
View File
@@ -66,20 +66,40 @@ func TestEvaluator_ImageWithReferencesIsCovered(t *testing.T) {
}
}
func TestEvaluator_TextTurnNoReferencesStillUncovered(t *testing.T) {
func TestEvaluator_TextTurnSearchedButNoReferencesIsUncovered(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "text-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但没有任何引用",
MessageType: "text",
References: []core.Reference{},
ID: "text-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但没有任何引用",
MessageType: "text",
References: []core.Reference{},
KnowledgeSearched: true,
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictUncovered || d.ReasonCode != ReasonUncoveredNoReferences {
t.Fatalf("verdict=%s reason=%s, want uncovered_no_references (text turn unchanged)", d.Verdict, d.ReasonCode)
t.Fatalf("verdict=%s reason=%s, want uncovered_no_references after knowledge search", d.Verdict, d.ReasonCode)
}
}
func TestEvaluator_TextTurnWithoutKnowledgeSearchIsCovered(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "smalltalk-turn",
Status: "succeeded",
Answer: "你好,我在。",
MessageType: "text",
KnowledgeSearched: false,
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictCovered || d.ReasonCode != ReasonCoveredNonKnowledgeResponse {
t.Fatalf("verdict=%s reason=%s, want covered_non_knowledge_response", d.Verdict, d.ReasonCode)
}
}
+11 -9
View File
@@ -8,8 +8,8 @@ import (
func TestEvaluatorVersion(t *testing.T) {
e := NewEvaluator()
if e.Version() != "v3" {
t.Errorf("Evaluator version = %q, want v3", e.Version())
if e.Version() != "v4" {
t.Errorf("Evaluator version = %q, want v4", e.Version())
}
}
@@ -85,10 +85,11 @@ func TestEvaluator_ProductionMissingProductPhraseTrumpsReferences(t *testing.T)
func TestEvaluator_UncoveredNoReferences(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "test-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但没有任何引用",
References: []core.Reference{},
ID: "test-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但没有任何引用",
References: []core.Reference{},
KnowledgeSearched: true,
}
d, err := e.Evaluate(turn)
if err != nil {
@@ -105,9 +106,10 @@ func TestEvaluator_UncoveredNoReferences(t *testing.T) {
func TestEvaluator_UncoveredNilReferences(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "test-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但引用为nil",
ID: "test-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但引用为nil",
KnowledgeSearched: true,
}
d, err := e.Evaluate(turn)
if err != nil {
+6 -5
View File
@@ -76,11 +76,12 @@ func TestService_EvaluateAndPersist_UncoveredNoReferences(t *testing.T) {
s, _ := NewService(repo)
turn := core.Turn{
ID: "turn-2",
TenantKey: "t1",
Channel: "dingtalk",
Status: "succeeded",
Answer: "正常回答但没有引用",
ID: "turn-2",
TenantKey: "t1",
Channel: "dingtalk",
Status: "succeeded",
Answer: "正常回答但没有引用",
KnowledgeSearched: true,
}
d, err := s.EvaluateAndPersist(context.Background(), turn)
+6 -5
View File
@@ -25,14 +25,15 @@ const (
// ReasonCode values.
const (
ReasonCoveredHasReferences = "covered_has_references"
ReasonUncoveredNoReferences = "uncovered_no_references"
ReasonUncoveredExplicitNoAnswer = "uncovered_explicit_no_answer"
ReasonCoveredVisualRecognition = "covered_visual_recognition"
ReasonCoveredHasReferences = "covered_has_references"
ReasonUncoveredNoReferences = "uncovered_no_references"
ReasonUncoveredExplicitNoAnswer = "uncovered_explicit_no_answer"
ReasonCoveredVisualRecognition = "covered_visual_recognition"
ReasonCoveredNonKnowledgeResponse = "covered_non_knowledge_response"
)
// EvaluatorVersion is the current version of the evaluator.
const EvaluatorVersion = "v3"
const EvaluatorVersion = "v4"
var (
ErrTurnNotSucceeded = errors.New("coverage evaluator: turn status must be succeeded")
+1
View File
@@ -14,4 +14,5 @@ var (
ErrAdministratorNotFound = errors.New("administrator not found")
ErrLastAdministrator = errors.New("last active administrator cannot be disabled")
ErrAdministratorAlreadyDisabled = errors.New("administrator already disabled")
ErrBootstrapCandidatePending = errors.New("bootstrap candidate pending")
)
+1 -1
View File
@@ -88,7 +88,7 @@ func (c *Controller) Handle(ctx context.Context, in SettingInput) (string, error
if err != nil {
return "", err
}
return "身份已验证,请回到服务器命令行等待确认。", nil
return "身份已验证,服务器终端将自动完成管理员绑定。", nil
}
if explicit && len(args) == 0 {
admin, err := c.repo.IsAdministrator(ctx, in.TenantKey, in.StaffID)
+2 -2
View File
@@ -372,7 +372,7 @@ func TestControllerDetectsBootstrapCandidate(t *testing.T) {
if r.candidate.Code != "123456" || r.candidate.StaffID != "a" {
t.Fatalf("candidate=%+v", r.candidate)
}
if !strings.Contains(out, "服务器命令行") || strings.Contains(out, "后台") {
t.Fatalf("reply=%q must direct confirmation back to the server CLI only", out)
if !strings.Contains(out, "服务器终端将自动完成管理员绑定") || strings.Contains(out, "后台") {
t.Fatalf("reply=%q must explain terminal auto-confirmation only", out)
}
}
+3
View File
@@ -147,6 +147,9 @@ func (r *SettingsRepository) ConfirmBootstrap(ctx context.Context, tenant string
var challengeID, staff, union, name, dept string
err = tx.QueryRow(ctx, `SELECT id,candidate_staff_id,candidate_union_id,candidate_display_name,candidate_department FROM admin_bootstrap_challenges WHERE tenant_key=$1 AND status='candidate_detected' AND expires_at>NOW() FOR UPDATE`, tenant).Scan(&challengeID, &staff, &union, &name, &dept)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return settings.ErrBootstrapCandidatePending
}
return err
}
if strings.TrimSpace(staff) == "" {
+196
View File
@@ -0,0 +1,196 @@
package scripts
import (
"os"
"strings"
"testing"
)
func TestDobotAIDingTalkComposeOwnsApplicationAndPostgres(t *testing.T) {
compose, err := os.ReadFile("../deploy/dobotai-dingtalk/compose.yaml")
if err != nil {
t.Fatal(err)
}
for _, required := range []string{
"dobotai-dingtalk:",
"container_name: dobotai-dingtalk",
"dobotai-postgres:",
"container_name: dobotai-postgres",
"dobotai-postgres-data:",
"depends_on:",
"condition: service_healthy",
"host.docker.internal:host-gateway",
"127.0.0.1:8090:8090",
"env_file:",
"read_only: true",
"/var/lib/dobotai/secrets",
} {
if !strings.Contains(string(compose), required) {
t.Errorf("compose missing %q", required)
}
}
configure, err := os.ReadFile("../deploy/dobotai-dingtalk/dobotai-dingtalk-configure")
if err != nil {
t.Fatal(err)
}
for _, required := range []string{"configure_env.py", "dobotai-dingtalk-start"} {
if !strings.Contains(string(configure), required) {
t.Errorf("configure command missing %q", required)
}
}
bind, err := os.ReadFile("../deploy/dobotai-dingtalk/dobotai-dingtalk-bind-admin")
if err != nil {
t.Fatal(err)
}
for _, required := range []string{"docker exec -it dobotai-dingtalk", "/usr/local/bin/dobotai bootstrap-admin"} {
if !strings.Contains(string(bind), required) {
t.Errorf("bind command missing %q", required)
}
}
}
func TestDobotAIDingTalkCredentialConfigurationNeverPassesSecretsOnCommandLine(t *testing.T) {
data, err := os.ReadFile("../deploy/dobotai-dingtalk/configure_env.py")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, required := range []string{"getpass.getpass", "os.replace", "0o600", "DINGTALK_CLIENT_SECRET"} {
if !strings.Contains(source, required) {
t.Errorf("credential configurator missing %q", required)
}
}
for _, forbidden := range []string{"subprocess.run([", "-v", "DINGTALK_CLIENT_SECRET="} {
if strings.Contains(source, forbidden) {
t.Errorf("credential configurator must not expose secret through process arguments: %q", forbidden)
}
}
}
func TestDobotAIDingTalkCredentialConfigurationExplainsEveryInputWithRedactedExamples(t *testing.T) {
configurator, err := os.ReadFile("../deploy/dobotai-dingtalk/configure_env.py")
if err != nil {
t.Fatal(err)
}
documentation, err := os.ReadFile("../deploy/dobotai-dingtalk/README.md")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(configurator), "格式示例:{example}") || !strings.Contains(string(documentation), "格式示例") {
t.Fatal("configuration guidance must label every example")
}
for _, example := range []string{
"http://host.docker.internal:8080",
`{"dingtalk:*":"你的Agent ID"}`,
"DINGTALK_CLIENT_SECRET",
} {
if !strings.Contains(string(configurator), example) {
t.Errorf("configurator missing redacted example %q", example)
}
if !strings.Contains(string(documentation), example) {
t.Errorf("documentation missing redacted example %q", example)
}
}
if strings.Contains(string(configurator), "DATABASE_URL") {
t.Fatal("database URL must be deployment-managed, not requested interactively")
}
if !strings.Contains(string(documentation), "dobotai-postgres") {
t.Fatal("documentation must explain the Compose-managed database")
}
}
func TestDobotAIDingTalkStartRollsBackToPreviousContainerImage(t *testing.T) {
data, err := os.ReadFile("../deploy/dobotai-dingtalk/dobotai-dingtalk-start")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, required := range []string{
"previous_image=",
"set_runtime_image",
"docker inspect --format",
"set_runtime_image \"$previous_image\"",
"\"${compose[@]}\" up -d --no-build --force-recreate dobotai-dingtalk",
} {
if !strings.Contains(source, required) {
t.Errorf("start script missing container upgrade rollback %q", required)
}
}
}
func TestDobotAIDingTalkDockerInstallerPreparesDirectoriesDatabaseAndMigrations(t *testing.T) {
data, err := os.ReadFile("../deploy/dobotai-dingtalk/install-docker.sh")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, required := range []string{
"container", "secrets", "backups", "migrations", "postgres.env",
"dobotai-postgres", "apply-migrations.sh", "docker compose", "umask 077",
} {
if !strings.Contains(source, required) {
t.Errorf("docker installer missing %q", required)
}
}
if strings.Contains(source, "dobotai-dingtalk up -d") {
t.Fatal("installer must prepare database but must not start the unconfigured application")
}
}
func TestDobotAIDingTalkExistingDatabaseMigrationHasBackupAndRollbackBoundary(t *testing.T) {
data, err := os.ReadFile("../deploy/dobotai-dingtalk/migrate-existing-database.sh")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, required := range []string{
"pg_dump -Fc", "pg_restore --list", "dobotai-postgres-data",
"dobotai-dingtalk", "rollback", "apply-migrations.sh",
} {
if !strings.Contains(source, required) {
t.Errorf("database adoption script missing %q", required)
}
}
}
func TestRootDockerInstallerIsCloneAndRunEntryPoint(t *testing.T) {
data, err := os.ReadFile("../install-docker.sh")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, required := range []string{
"docker build",
"dobotai-dingtalk:local-",
"install_docker_if_missing",
"docker compose version",
"DOBOTAI_SOURCE_ROOT",
"deploy/dobotai-dingtalk/install-docker.sh",
} {
if !strings.Contains(source, required) {
t.Errorf("root installer missing %q", required)
}
}
if strings.Contains(source, "${DOBOTAI_IMAGE") || strings.Contains(source, "DOBOTAI_IMAGE=?") {
t.Fatal("root installer must not require an operator supplied DOBOTAI_IMAGE")
}
}
func TestRootDockerInstallerBuildsWithInternalGoModuleProxy(t *testing.T) {
installer, err := os.ReadFile("../install-docker.sh")
if err != nil {
t.Fatal(err)
}
dockerfile, err := os.ReadFile("../Dockerfile")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(installer), "GOPROXY=https://goproxy.cn,direct") {
t.Fatal("root installer must pass an internal default GOPROXY to docker build")
}
if !strings.Contains(string(dockerfile), "ARG GOPROXY") || !strings.Contains(string(dockerfile), "GOPROXY=$GOPROXY go mod download") {
t.Fatal("Dockerfile must accept and use the root installer GOPROXY")
}
}