Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e65028d1d | |||
| c7d2629a77 | |||
| b49cb5eaf9 | |||
| 16aa981028 | |||
| f800127461 | |||
| d6a781ba5b | |||
| 168e401bc0 | |||
| 58b37f1c24 | |||
| 6a3f505965 | |||
| a0f1fbf391 | |||
| 49e0789fa5 | |||
| d77fbd675b | |||
| a615693bd0 | |||
| febf99b549 | |||
| b4b61c0aa5 | |||
| 6153533b46 | |||
| 27d4d45af4 | |||
| a31156dc4e | |||
| f408096611 | |||
| 218ab657d6 | |||
| 5c5e4494cb | |||
| e584b17929 | |||
| 8027635330 | |||
| 563329320b | |||
| 9abbb2cb2b | |||
| 4c5b1e3b5a |
@@ -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
+23
@@ -0,0 +1,23 @@
|
||||
FROM golang:1.26.5-bookworm AS build
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
ARG GOPROXY=https://proxy.golang.org,direct
|
||||
ENV GOPROXY=${GOPROXY}
|
||||
RUN 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"]
|
||||
@@ -37,6 +37,74 @@ 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。
|
||||
|
||||
## 部署方式
|
||||
|
||||
| 方式 | 适用场景 | 数据库 | 入口 |
|
||||
| --- | --- | --- | --- |
|
||||
| 源码/原生二进制 | 本地开发、快速调试 | 手工配置阿里云RDS连接 | `go run ./cmd/dobotai` |
|
||||
| Docker | 测试、预发布、生产候选 | 既有阿里云RDS;Docker不运行数据库 | `sudo bash ./install-docker.sh` |
|
||||
|
||||
### 源码部署
|
||||
|
||||
```bash
|
||||
go run ./cmd/dobotai
|
||||
curl --noproxy '*' -fsS http://127.0.0.1:8090/healthz
|
||||
```
|
||||
|
||||
源码方式不自动安装Docker、不创建目录、不检查RDS表,也不适合作为长期渠道消费者。
|
||||
|
||||
### Docker + 阿里云RDS部署
|
||||
|
||||
克隆仓库后在根目录执行:
|
||||
|
||||
```bash
|
||||
sudo bash ./install-docker.sh
|
||||
sudo dobotai-dingtalk-configure
|
||||
```
|
||||
|
||||
第一条命令自动安装Docker/Compose(如缺失)、构建应用镜像、准备目录和配置模板;第二条命令安全录入RDS、WeKnora与钉钉配置,然后检查RDS表:空库初始化`000001`至`000021`,完整表不写入,异常/半成品表拒绝启动。Docker只运行`dobotai-dingtalk`,不创建`dobotai-postgres`、本地数据卷或本地数据库。
|
||||
|
||||
完整安装、RDS检查、升级和回滚见 [Docker部署手册](deploy/dobotai-dingtalk/README.md)。
|
||||
|
||||
## 钉钉 Stream
|
||||
|
||||
使用官方 `dingtalk-stream-sdk-go`,不暴露公网回调入口。
|
||||
@@ -65,10 +133,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 +197,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
|
||||
```
|
||||
|
||||
迁移前应执行逻辑备份并验证恢复;迁移、二进制部署和钉钉渠道切换是独立步骤,不能把健康检查代替真实渠道验收。
|
||||
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+19
-34
@@ -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 {
|
||||
@@ -75,10 +79,22 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defaultMode := reasoningmode.ModeQuick
|
||||
if raw := strings.TrimSpace(os.Getenv("DOBOTAI_DEFAULT_MODE")); raw != "" {
|
||||
switch raw {
|
||||
case "reasoning":
|
||||
defaultMode = reasoningmode.ModeReasoning
|
||||
case "quick":
|
||||
defaultMode = reasoningmode.ModeQuick
|
||||
default:
|
||||
log.Fatal("DOBOTAI_DEFAULT_MODE must be quick or reasoning")
|
||||
}
|
||||
}
|
||||
reasoningService, err := reasoningmode.NewService(
|
||||
store,
|
||||
os.Getenv("DOBOTAI_QUICK_AGENT_ID"),
|
||||
os.Getenv("DOBOTAI_REASONING_AGENT_ID"),
|
||||
reasoningmode.WithDefaultMode(defaultMode),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -131,11 +147,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 +340,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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE reasoning_mode_states
|
||||
DROP COLUMN user_selected;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 000022_reasoning_default_mode: support an operator-configurable default
|
||||
-- mode for the /think reasoning router. The new column records whether a
|
||||
-- user has explicitly chosen a mode (/think on|off); users who never have
|
||||
-- will follow DOBOTAI_DEFAULT_MODE instead of the hardcoded quick answer.
|
||||
ALTER TABLE reasoning_mode_states
|
||||
ADD COLUMN user_selected BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,34 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUp022AddsReasoningUserSelectedColumn(t *testing.T) {
|
||||
data, err := os.ReadFile("000022_reasoning_default_mode.up.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
for _, required := range []string{
|
||||
"ALTER TABLE REASONING_MODE_STATES",
|
||||
"ADD COLUMN USER_SELECTED BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("up migration missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown022DropsUserSelectedColumn(t *testing.T) {
|
||||
data, err := os.ReadFile("000022_reasoning_default_mode.down.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
if !strings.Contains(source, "DROP COLUMN USER_SELECTED") {
|
||||
t.Errorf("down migration missing %q", "DROP COLUMN USER_SELECTED")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
# DobotAI Docker部署(阿里云RDS)
|
||||
|
||||
## 定位
|
||||
|
||||
DobotAI是钉钉渠道编排与交付服务。Docker只运行应用容器`dobotai-dingtalk`;数据库唯一使用已有的阿里云RDS PostgreSQL。
|
||||
|
||||
```text
|
||||
钉钉 Stream
|
||||
↓
|
||||
dobotai-dingtalk 容器
|
||||
├─ 阿里云RDS PostgreSQL:Turn、会话、反馈、管理员、覆盖判定
|
||||
└─ 既有WeKnora:Agent、检索、知识、模型
|
||||
```
|
||||
|
||||
禁止创建或使用:
|
||||
|
||||
```text
|
||||
dobotai-postgres 容器
|
||||
dobotai-postgres-data 数据卷
|
||||
本地PostgreSQL作为DobotAI目标数据库
|
||||
```
|
||||
|
||||
## 架构与目录
|
||||
|
||||
```text
|
||||
宿主机
|
||||
├── dobotai-dingtalk 唯一应用容器,host网络
|
||||
├── /opt/dobotai/container/ Compose、RDS检查器、SQL迁移、启动脚本
|
||||
├── /opt/dobotai/config/dobotai.env 0600,RDS/WeKnora/钉钉配置
|
||||
├── /opt/dobotai/secrets/ 0700,DobotAI SecretStore
|
||||
└── /opt/dobotai/backups/ 发布前RDS备份记录,不保存Docker数据库卷
|
||||
|
||||
阿里云RDS
|
||||
└── DobotAI专用PostgreSQL数据库
|
||||
```
|
||||
|
||||
应用使用`network_mode: host`,因此通过RDS私网地址访问云数据库、通过`127.0.0.1:8080`访问宿主机现有WeKnora。不会开放新的数据库端口;应用HTTP仍由`DOBOTAI_LISTEN_ADDR=127.0.0.1:8090`约束。
|
||||
|
||||
## 安装
|
||||
|
||||
克隆完整仓库后,只执行:
|
||||
|
||||
```bash
|
||||
sudo bash ./install-docker.sh
|
||||
```
|
||||
|
||||
根脚本自动:
|
||||
|
||||
```text
|
||||
检查或安装Docker Engine和Docker Compose v2(APT系统)
|
||||
以当前Git提交构建 dobotai-dingtalk:local-<Git短SHA>
|
||||
创建 /opt/dobotai/{container,config,secrets,backups}
|
||||
复制应用Compose、RDS检查器和21个SQL迁移
|
||||
生成权限0600的应用环境模板
|
||||
安装三条短命令
|
||||
```
|
||||
|
||||
它不会连接、创建或修改RDS,也不会启动未配置的钉钉应用。
|
||||
|
||||
## 配置、RDS检查与启动
|
||||
|
||||
安装后执行:
|
||||
|
||||
```bash
|
||||
sudo dobotai-dingtalk-configure
|
||||
```
|
||||
|
||||
配置器分字段录入阿里云RDS(主机、端口、数据库名、账号、密码),自动组装连接串;WeKnora和钉钉凭证密钥不回显,环境文件原子写入且权限0600。
|
||||
|
||||
`DOBOTAI_TENANT_KEY`不是WeKnora Agent租户键。它是DobotAI内部数据隔离键,首次安装时自动生成并固定保存;配置器不会要求填写或改动它。WeKnora侧只需要填写实际的两个Agent ID。
|
||||
|
||||
配置器会要求两个不同的WeKnora Agent ID,并自动生成`DOBOTAI_AGENT_BINDINGS={"dingtalk:*":"快速问答Agent ID"}`。`DOBOTAI_INGRESS_TOKEN`是保护本机`POST /v1/qa`内部接口的Bearer Token,不用于钉钉或WeKnora;同样由安装器自动生成。
|
||||
|
||||
| 配置 | 示例 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `DATABASE_URL` | `postgres://USER:***@rds-endpoint:5432/dobotai?sslmode=require` | DobotAI专用阿里云RDS PostgreSQL |
|
||||
| `WEKNORA_BASE_URL` | `http://127.0.0.1:8080` | 宿主机既有WeKnora |
|
||||
| `WEKNORA_API_KEY` | `<你的WeKnora API Key>` | DobotAI专用Key |
|
||||
| `DOBOTAI_QUICK_AGENT_ID` | `<快速问答Agent ID>` | 默认快速问答实际调用的Agent |
|
||||
| `DOBOTAI_REASONING_AGENT_ID` | `<智能推理Agent ID>` | `/think`和持续智能推理实际调用的Agent |
|
||||
| `DINGTALK_CLIENT_ID` | `dingxxxxxxxx` | 钉钉AppKey |
|
||||
| `DINGTALK_CLIENT_SECRET` | `<你的钉钉 AppSecret>` | 钉钉AppSecret |
|
||||
|
||||
回答图片外发OSS同为分字段录入的可选整组(紧随RDS之后、WeKnora/钉钉凭证之前):
|
||||
|
||||
| 配置 | 示例 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT` | `https://oss-cn-shenzhen.aliyuncs.com` | 私有Bucket接入地址 |
|
||||
| `DOBOTAI_OUTBOUND_IMAGE_OSS_REGION` | `cn-shenzhen` | Bucket所在地域 |
|
||||
| `DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET` | `dobot-xxx` | 外发图片专用私有Bucket |
|
||||
| `DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID` | `<AK ID>` | 仅授权该Bucket的AK |
|
||||
| `DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET` | `<AK Secret>` | 仅授权该Bucket的SK(不回显) |
|
||||
| `DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL` | `https://host/dingtalk/i` | 钉钉可见的稳定HTTPS入口,不含凭据 |
|
||||
|
||||
OSS规则:六项全部留空即整组禁用图片外发(答案含`resource://`图片时按无图降级);填写任一项则六项必填,缺项拒绝保存。运行时仅改写WeKnora `resource://`引用为短签HTTPS,私有Bucket由稳定基础地址代理,`<img>`外链与`data:`剥除。禁止把OSS凭据写入Git或命令行。
|
||||
|
||||
保存后脚本自动执行以下RDS门禁:
|
||||
|
||||
```text
|
||||
1. 使用RDS连接串执行 pg_isready
|
||||
2. 检查DobotAI核心表
|
||||
3. 数据库没有任何public表:按000001~000021初始化
|
||||
4. 所有核心表存在:判定正常,不执行任何DDL
|
||||
5. 部分表、缺表或非空异常库:拒绝初始化和启动
|
||||
6. RDS通过后才启动 dobotai-dingtalk 并检查 /healthz
|
||||
```
|
||||
|
||||
这保证不会把已有RDS表误当成空库重建,也不会把半成品或错误库静默升级。
|
||||
|
||||
## 日常命令
|
||||
|
||||
```bash
|
||||
# 重新检查RDS并启动/升级应用
|
||||
sudo dobotai-dingtalk-start
|
||||
|
||||
# 交互更新RDS、WeKnora、钉钉配置后启动
|
||||
sudo dobotai-dingtalk-configure
|
||||
|
||||
# 仅首位管理员且无有效管理员时使用
|
||||
sudo dobotai-dingtalk-bind-admin
|
||||
|
||||
# 健康与版本
|
||||
curl --noproxy '*' -fsS http://127.0.0.1:8090/healthz
|
||||
curl --noproxy '*' -fsS http://127.0.0.1:8090/version
|
||||
```
|
||||
|
||||
## 升级与回滚
|
||||
|
||||
应用升级仅替换`dobotai-dingtalk`镜像。启动前再次检查RDS;健康失败时恢复上一应用镜像(如存在)。RDS不是容器资产,不能被`docker compose down`、镜像替换或本机卷操作影响。
|
||||
|
||||
RDS有实际数据时,数据库备份、SQL变更与恢复应在阿里云RDS发布流程中完成并验证;不要把应用镜像回滚误当成数据库回滚。
|
||||
|
||||
## 首位管理员
|
||||
|
||||
应用和钉钉Stream运行后:
|
||||
|
||||
```bash
|
||||
sudo dobotai-dingtalk-bind-admin
|
||||
```
|
||||
|
||||
终端会显示10分钟有效的六码;候选管理员私聊机器人:
|
||||
|
||||
```text
|
||||
/setting <六码绑定码>
|
||||
```
|
||||
|
||||
已有有效管理员时绑定命令会拒绝,避免提权。
|
||||
|
||||
## 不执行的操作
|
||||
|
||||
```text
|
||||
不创建本地PostgreSQL容器或数据卷
|
||||
不迁移、删除或覆盖现有本地 dobotai-test-postgres
|
||||
不创建、删除或重置阿里云RDS数据库
|
||||
不把RDS连接串、Token、钉钉凭据写入Git、命令行或日志
|
||||
不重建WeKnora、Neo4j、Redis、Elasticsearch
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
dobotai-dingtalk:
|
||||
image: ${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE in runtime.env}
|
||||
container_name: dobotai-dingtalk
|
||||
network_mode: host
|
||||
user: "${DOBOTAI_RUNTIME_UID:?set DOBOTAI_RUNTIME_UID}:${DOBOTAI_RUNTIME_GID:?set DOBOTAI_RUNTIME_GID}"
|
||||
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
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=32m
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, unquote, urlsplit
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
FIELDS = (
|
||||
("WEKNORA_BASE_URL", "WeKnora 地址", "应用容器通过宿主机网络访问既有WeKnora。", "http://127.0.0.1:8080", False),
|
||||
("WEKNORA_API_KEY", "WeKnora API Key", "DobotAI专用WeKnora API Key。", "<你的WeKnora API Key>", True),
|
||||
("DOBOTAI_QUICK_AGENT_ID", "快速问答Agent ID", "默认问答实际调用的WeKnora Agent。", "<快速问答Agent ID>", False),
|
||||
("DOBOTAI_REASONING_AGENT_ID", "智能推理Agent ID", "/think和持续智能推理实际调用的WeKnora Agent。", "<智能推理Agent ID>", False),
|
||||
("DINGTALK_CLIENT_ID", "钉钉 Client ID", "钉钉应用Client ID/AppKey。", "dingxxxxxxxx", False),
|
||||
("DINGTALK_CLIENT_SECRET", "钉钉 Client Secret", "钉钉应用Client Secret/AppSecret。", "<你的钉钉 AppSecret>", True),
|
||||
)
|
||||
|
||||
def prompt_rds(values):
|
||||
"""分字段录入阿里云RDS并组装DATABASE_URL;已有值作为默认。"""
|
||||
existing = values.get("DATABASE_URL", "").strip()
|
||||
host = port = database = user = password = ""
|
||||
if existing:
|
||||
u = urlsplit(existing)
|
||||
host = u.hostname or ""
|
||||
port = str(u.port or "") if u.port else ""
|
||||
database = u.path.lstrip("/")
|
||||
user = unquote(u.username or "")
|
||||
password = unquote(u.password or "")
|
||||
|
||||
def ask(key, label, default, secret=False):
|
||||
suffix = "(已配置,直接回车保留)" if default else "(必填)"
|
||||
reader = getpass.getpass if secret else input
|
||||
entered = reader(f"{label}{suffix}: ").strip()
|
||||
return entered or default
|
||||
|
||||
print("\n阿里云RDS PostgreSQL(分字段填写)\n用途:DobotAI专用数据库;空库自动初始化,已有表只校验不改结构。")
|
||||
host = ask("host", "RDS主机地址", host)
|
||||
port = ask("port", "RDS端口", port or "5432")
|
||||
database = ask("database", "数据库名", database)
|
||||
user = ask("user", "数据库账号", user)
|
||||
password = ask("password", "数据库密码", password, secret=True)
|
||||
if not all((host, port, database, user, password)):
|
||||
raise SystemExit("RDS字段不完整,未保存。")
|
||||
if not database.startswith(("dobotai", "dobotai_")):
|
||||
print("警告:数据库名不是DobotAI专用库,继续前请确认。")
|
||||
values["DATABASE_URL"] = f"postgres://{quote(user, safe='')}:{quote(password, safe='')}@{host}:{port}/{database}"
|
||||
|
||||
OSS_FIELDS = (
|
||||
("DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT", "OSS Endpoint", "私有Bucket的接入地址。", "https://oss-cn-shenzhen.aliyuncs.com", False),
|
||||
("DOBOTAI_OUTBOUND_IMAGE_OSS_REGION", "OSS Region", "Bucket所在地域。", "cn-shenzhen", False),
|
||||
("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET", "OSS Bucket", "外发图片专用私有Bucket。", "dobot-xxx", False),
|
||||
("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID", "OSS AccessKey ID", "仅授权该Bucket的AK。", "<AK ID>", False),
|
||||
("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET", "OSS AccessKey Secret", "仅授权该Bucket的SK。", "<AK Secret>", True),
|
||||
("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL", "图片稳定外发基础地址", "钉钉可见的稳定HTTPS入口,不含凭据。", "https://host/dingtalk/i", False),
|
||||
)
|
||||
|
||||
def prompt_oss(values):
|
||||
"""OSS整组录入:全部留空即禁用;任一项填写则六项必填。"""
|
||||
print("\n回答图片外发OSS(可选,OSS整组录入;直接回车表示整组禁用)")
|
||||
entered = {}
|
||||
for key, label, description, example, secret in OSS_FIELDS:
|
||||
default = values.get(key, "").strip()
|
||||
suffix = "(已配置,直接回车保留)" if default else ""
|
||||
print(f"用途:{description} 格式示例:{example}")
|
||||
reader = getpass.getpass if secret else input
|
||||
value = reader(f"{label}{suffix}: ").strip()
|
||||
entered[key] = value or default
|
||||
if not any(entered.values()):
|
||||
for key, *_ in OSS_FIELDS:
|
||||
values.pop(key, None)
|
||||
return
|
||||
missing = [label for key, label, *_ in OSS_FIELDS if not entered[key]]
|
||||
if missing:
|
||||
raise SystemExit("OSS为整组可选能力,以下项未填写:" + "、".join(missing))
|
||||
values.update(entered)
|
||||
|
||||
|
||||
|
||||
def parse_env(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 write_env(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)
|
||||
rendered.extend(f"{key}={value}" for key,value in values.items() if key not in written)
|
||||
temp=path.with_name(path.name+".new"); temp.write_text("\n".join(rendered)+"\n",encoding="utf-8"); os.chmod(temp,0o600); os.replace(temp,path)
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description="配置DobotAI阿里云RDS和渠道凭证")
|
||||
parser.add_argument("--env-file",required=True); parser.add_argument("--schema-script",required=True); parser.add_argument("--start-script",required=True)
|
||||
args=parser.parse_args(); path=Path(args.env_file)
|
||||
if not path.is_file(): raise SystemExit("DobotAI环境文件不存在,请先执行根目录install-docker.sh。")
|
||||
lines,values=parse_env(path)
|
||||
prompt_rds(values)
|
||||
prompt_oss(values)
|
||||
for key,label,description,example,secret in FIELDS:
|
||||
suffix="(已配置,直接回车保留)" if values.get(key,"").strip() else "(必填)"
|
||||
print(f"\n{label}\n用途:{description}\n格式示例:{example}")
|
||||
entered=(getpass.getpass if secret else input)(f"{label}{suffix}: ").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))
|
||||
if values["DOBOTAI_QUICK_AGENT_ID"] == values["DOBOTAI_REASONING_AGENT_ID"]:
|
||||
raise SystemExit("快速问答Agent ID与智能推理Agent ID必须不同。")
|
||||
values["DOBOTAI_AGENT_BINDINGS"] = json.dumps({"dingtalk:*": values["DOBOTAI_QUICK_AGENT_ID"]}, separators=(",", ":"))
|
||||
write_env(path,lines,values)
|
||||
subprocess.run([args.schema_script], check=True)
|
||||
os.execv(args.start_script,[args.start_script])
|
||||
if __name__=="__main__":
|
||||
try: main()
|
||||
except KeyboardInterrupt: sys.exit(130)
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/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,23 @@
|
||||
#!/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"
|
||||
[[ -r "$compose_file" && -r "$runtime_env" ]] || { echo "容器资产未安装。" >&2; exit 1; }
|
||||
"$container_dir/rds-schema-init.sh"
|
||||
compose=(docker compose --env-file "$runtime_env" -f "$compose_file")
|
||||
previous_image="$(docker inspect -f '{{.Config.Image}}' dobotai-dingtalk 2>/dev/null || true)"
|
||||
rollback() {
|
||||
"${compose[@]}" rm -sf dobotai-dingtalk >/dev/null 2>&1 || true
|
||||
if [[ -n "$previous_image" ]]; then "${compose[@]}" up -d --no-build --force-recreate dobotai-dingtalk >/dev/null 2>&1 || true; fi
|
||||
}
|
||||
trap rollback ERR
|
||||
"${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
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/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"
|
||||
runtime_env="$container_dir/runtime.env"
|
||||
image="${DOBOTAI_IMAGE:?root install-docker.sh must provide the local image}"
|
||||
bin_dir="${DOBOTAI_BIN_DIR:-/usr/local/sbin}"
|
||||
|
||||
[[ ${EUID} -eq 0 ]] || { echo "请使用sudo执行安装脚本。" >&2; exit 1; }
|
||||
docker image inspect "$image" >/dev/null 2>&1 || { echo "应用镜像不存在。" >&2; exit 1; }
|
||||
install -d -m 0750 "$home" "$container_dir" "$config_dir" "$backup_dir" "$container_dir/migrations"
|
||||
install -d -m 0700 -o 10001 -g 10001 "$secret_dir"
|
||||
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/rds-schema-init.sh" "$container_dir/rds-schema-init.sh"
|
||||
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 0644 "$source_root"/db/migrations/*.up.sql "$container_dir/migrations/"
|
||||
|
||||
if [[ ! -f "$app_env" ]]; then
|
||||
umask 077
|
||||
read -r tenant_suffix ingress_token < <(python3 -c 'import secrets; print(secrets.token_hex(12), secrets.token_urlsafe(32))')
|
||||
cat > "$app_env" <<'EOF'
|
||||
DOBOTAI_LISTEN_ADDR=127.0.0.1:8090
|
||||
DATABASE_URL=
|
||||
WEKNORA_BASE_URL=http://127.0.0.1:8080
|
||||
WEKNORA_API_KEY=
|
||||
DOBOTAI_ADMIN_TOKEN=
|
||||
DOBOTAI_AGENT_BINDINGS=
|
||||
DOBOTAI_QUICK_AGENT_ID=
|
||||
DOBOTAI_REASONING_AGENT_ID=
|
||||
DINGTALK_CLIENT_ID=
|
||||
DINGTALK_CLIENT_SECRET=
|
||||
EOF
|
||||
printf 'DOBOTAI_TENANT_KEY=dobotai-%s\n' "$tenant_suffix" >> "$app_env"
|
||||
printf 'DOBOTAI_INGRESS_TOKEN=%s\n' "$ingress_token" >> "$app_env"
|
||||
chmod 0600 "$app_env"
|
||||
fi
|
||||
umask 077
|
||||
cat > "$runtime_env" <<EOF
|
||||
DOBOTAI_IMAGE=$image
|
||||
DOBOTAI_RUNTIME_UID=10001
|
||||
DOBOTAI_RUNTIME_GID=10001
|
||||
DOBOTAI_ENV_FILE=$app_env
|
||||
DOBOTAI_SECRET_DIR_HOST=$secret_dir
|
||||
EOF
|
||||
chmod 0600 "$runtime_env"
|
||||
install -d -m 0755 "$bin_dir"
|
||||
for command in configure start bind-admin; do
|
||||
target="$bin_dir/dobotai-dingtalk-$command"
|
||||
cat > "$target" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export DOBOTAI_HOME='$home'
|
||||
exec '$container_dir/dobotai-dingtalk-$command'
|
||||
EOF
|
||||
chmod 0750 "$target"
|
||||
done
|
||||
# configure is a Python entrypoint rather than a shell asset.
|
||||
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' --schema-script '$container_dir/rds-schema-init.sh' --start-script '$bin_dir/dobotai-dingtalk-start'
|
||||
EOF
|
||||
chmod 0750 "$bin_dir/dobotai-dingtalk-configure"
|
||||
|
||||
echo "应用容器资产已安装;尚未连接或修改阿里云RDS。"
|
||||
echo "下一步执行:sudo dobotai-dingtalk-configure"
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
container_dir="${DOBOTAI_CONTAINER_DIR:-/opt/dobotai-test/container}"
|
||||
service="${DOBOTAI_LEGACY_SERVICE:-dobotai-test.service}"
|
||||
runtime_user="${DOBOTAI_RUNTIME_USER:-dobotai-test}"
|
||||
env_file="${DOBOTAI_ENV_FILE:-/etc/dobotai-test/dobotai.env}"
|
||||
secret_dir="${DOBOTAI_SECRET_DIR_HOST:-/var/lib/dobotai-test/secrets}"
|
||||
image="${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE to the reviewed image tag before installation}"
|
||||
backup_root="${DOBOTAI_BACKUP_ROOT:-/opt/dobotai-test/backups}"
|
||||
|
||||
if [[ ${EUID} -ne 0 ]]; then
|
||||
echo "请使用 sudo 执行安装脚本。" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! docker image inspect "$image" >/dev/null 2>&1; then
|
||||
echo "候选镜像不存在:$image" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -r "$env_file" || ! -d "$secret_dir" ]]; then
|
||||
echo "现有 DobotAI 环境文件或密钥目录不存在,拒绝安装。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
uid="$(id -u "$runtime_user")"
|
||||
gid="$(id -g "$runtime_user")"
|
||||
stamp="$(date -u '+%Y%m%dT%H%M%SZ')"
|
||||
backup_dir="$backup_root/pre-container-$stamp"
|
||||
install -d -m 0700 "$backup_dir" "$container_dir"
|
||||
cp -p /opt/dobotai-test/bin/dobotai "$backup_dir/dobotai.native"
|
||||
cp -p /etc/systemd/system/dobotai-test.service "$backup_dir/dobotai-test.service"
|
||||
sha256sum "$backup_dir/dobotai.native" > "$backup_dir/SHA256SUMS"
|
||||
|
||||
install -m 0644 "$script_dir/compose.yaml" "$container_dir/compose.yaml"
|
||||
install -m 0750 "$script_dir/dobotai-dingtalk-start" /usr/local/sbin/dobotai-dingtalk-start
|
||||
install -m 0750 "$script_dir/dobotai-dingtalk-configure" /usr/local/sbin/dobotai-dingtalk-configure
|
||||
install -m 0750 "$script_dir/dobotai-dingtalk-bind-admin" /usr/local/sbin/dobotai-dingtalk-bind-admin
|
||||
install -m 0700 "$script_dir/configure_env.py" "$container_dir/configure_env.py"
|
||||
|
||||
runtime_tmp="$container_dir/runtime.env.new"
|
||||
umask 077
|
||||
cat > "$runtime_tmp" <<EOF
|
||||
DOBOTAI_IMAGE=$image
|
||||
DOBOTAI_RUNTIME_UID=$uid
|
||||
DOBOTAI_RUNTIME_GID=$gid
|
||||
DOBOTAI_ENV_FILE=$env_file
|
||||
DOBOTAI_SECRET_DIR_HOST=$secret_dir
|
||||
EOF
|
||||
chmod 0600 "$runtime_tmp"
|
||||
mv -f "$runtime_tmp" "$container_dir/runtime.env"
|
||||
|
||||
echo "容器资产已安装,旧服务仍在运行。"
|
||||
echo "下一步执行:sudo dobotai-dingtalk-configure"
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
home="${DOBOTAI_HOME:-/opt/dobotai}"
|
||||
app_env="$home/config/dobotai.env"
|
||||
migrations_dir="$home/container/migrations"
|
||||
[[ -r "$app_env" && -d "$migrations_dir" ]] || { echo "RDS检查资产未安装。" >&2; exit 1; }
|
||||
temp_env="$(mktemp)"; trap 'rm -f "$temp_env"' EXIT
|
||||
python3 - "$app_env" "$temp_env" <<'PY'
|
||||
from pathlib import Path
|
||||
import os,sys
|
||||
values={}
|
||||
for line in Path(sys.argv[1]).read_text(encoding='utf-8').splitlines():
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key,value=line.split('=',1); values[key]=value
|
||||
url=values.get('DATABASE_URL','').strip()
|
||||
if not (url.startswith('postgres://') or url.startswith('postgresql://')): raise SystemExit('DATABASE_URL必须是阿里云RDS PostgreSQL连接串。')
|
||||
Path(sys.argv[2]).write_text('DATABASE_URL='+url+'\n',encoding='utf-8'); os.chmod(sys.argv[2],0o600)
|
||||
PY
|
||||
rds_shell() { docker run --rm -i --network host --env-file "$temp_env" postgres:16-alpine sh -ec "$1"; }
|
||||
rds_shell 'pg_isready -d "$DATABASE_URL" >/dev/null' || true
|
||||
if ! probe_error="$(rds_shell 'exec psql -At -v ON_ERROR_STOP=1 "$DATABASE_URL" -c "SELECT 1"' 2>&1)"; then
|
||||
case "$probe_error" in
|
||||
*"does not exist"*|*"不存在"*)
|
||||
echo "阿里云RDS目标数据库不存在;请RDS管理员创建DobotAI专用数据库并授予应用账号连接和建表权限。" >&2
|
||||
;;
|
||||
*)
|
||||
echo "无法连接阿里云RDS目标数据库;请管理员检查网络、白名单、TLS、账号权限和连接串。" >&2
|
||||
;;
|
||||
esac
|
||||
exit 1
|
||||
fi
|
||||
sql() { rds_shell 'exec psql -v ON_ERROR_STOP=1 "$DATABASE_URL"'; }
|
||||
sql_at() { rds_shell 'exec psql -At -v ON_ERROR_STOP=1 "$DATABASE_URL"'; }
|
||||
state=$(printf "%s\n" "WITH expected(name) AS (VALUES ('turns'),('session_mappings'),('messages'),('coverage_decisions'),('support_cases'),('system_config_revisions'),('administrators'),('setting_sessions'),('user_preferences'),('reasoning_mode_states'),('reasoning_mode_turns')) SELECT CASE WHEN to_regclass('public.turns') IS NULL AND (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public')=0 THEN 'empty' WHEN (SELECT COUNT(*) FROM expected e JOIN information_schema.tables t ON t.table_schema='public' AND t.table_name=e.name)=11 THEN 'normal' ELSE 'inconsistent' END;" | sql_at)
|
||||
case "$state" in
|
||||
normal) echo "阿里云RDS表结构检查通过;不执行初始化。"; exit 0;;
|
||||
inconsistent) echo "阿里云RDS表结构不完整或不是空库,拒绝初始化和启动。" >&2; exit 1;;
|
||||
empty) echo "阿里云RDS为空,开始初始化DobotAI表。";;
|
||||
*) echo "无法判定阿里云RDS表状态。" >&2; exit 1;;
|
||||
esac
|
||||
for migration in "$migrations_dir"/*.up.sql; do printf 'BEGIN;\n'; cat "$migration"; printf '\nCOMMIT;\n'; done | sql
|
||||
echo "阿里云RDS初始化完成。"
|
||||
@@ -0,0 +1,6 @@
|
||||
# 非敏感容器运行参数;install-test.sh 会根据测试服务器实际用户和目录生成此文件。
|
||||
DOBOTAI_IMAGE=dobotai-dingtalk:test
|
||||
DOBOTAI_RUNTIME_UID=996
|
||||
DOBOTAI_RUNTIME_GID=988
|
||||
DOBOTAI_ENV_FILE=/etc/dobotai-test/dobotai.env
|
||||
DOBOTAI_SECRET_DIR_HOST=/var/lib/dobotai-test/secrets
|
||||
@@ -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 必须针对选定的官方版本和视觉模型执行契约测试。
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
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 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 "请先安装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
|
||||
}
|
||||
install_docker_if_missing
|
||||
docker compose version >/dev/null
|
||||
|
||||
commit_sha="$(git rev-parse HEAD)"
|
||||
commit_short="$(git rev-parse --short=12 HEAD)"
|
||||
image="dobotai-dingtalk:local-${commit_short}"
|
||||
echo "[1/2] 构建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=$(date -u +%Y-%m-%dT%H:%M:%SZ)" --label "org.opencontainers.image.revision=${commit_sha}" --tag "$image" "$repo_root"
|
||||
|
||||
echo "[2/2] 安装应用容器资产;阿里云RDS将在配置后检查或初始化"
|
||||
export DOBOTAI_IMAGE="$image"
|
||||
export DOBOTAI_SOURCE_ROOT="$repo_root"
|
||||
exec bash "$repo_root/deploy/dobotai-dingtalk/install-docker.sh"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,11 @@ import (
|
||||
const displayReferenceMarkerPattern = `\[\[ref[\t ]+id[\t ]*=[\t ]*["']c[0-9]+["'][\t ]*\]\]`
|
||||
|
||||
var (
|
||||
displayReferenceMarkerRE = regexp.MustCompile(displayReferenceMarkerPattern)
|
||||
displayReferenceMarkerRE = regexp.MustCompile(displayReferenceMarkerPattern)
|
||||
// bareInternalSourceHandleRE matches WeKnora request-local source aliases
|
||||
// leaked by a model (for example [d21], [c12], or [w3]). It intentionally
|
||||
// does not match ordinary Markdown links or arbitrary bracketed content.
|
||||
bareInternalSourceHandleRE = regexp.MustCompile(`(?i)\[(?:[dcw][0-9]+)(?:[ ]*[,,][ ]*(?:[dcw][0-9]+))*\]`)
|
||||
leadingReferenceGroupColonRE = regexp.MustCompile(`(?m)^[ ]*(?:` + displayReferenceMarkerPattern + `[ ]*)+[::][ ]*`)
|
||||
spacedReferenceGroupRE = regexp.MustCompile(`[ ]+(?:` + displayReferenceMarkerPattern + `[ ]*)+`)
|
||||
spaceBeforeDisplayPunctuation = regexp.MustCompile(`[ ]+([,。;:!?、,.;:!?])`)
|
||||
@@ -19,12 +23,13 @@ func sanitizeDisplayAnswer(content string) string {
|
||||
if index := strings.LastIndex(content, "[[ref"); index >= 0 && !strings.Contains(content[index:], "]]") {
|
||||
content = content[:index]
|
||||
}
|
||||
if !displayReferenceMarkerRE.MatchString(content) {
|
||||
if !displayReferenceMarkerRE.MatchString(content) && !bareInternalSourceHandleRE.MatchString(content) {
|
||||
return content
|
||||
}
|
||||
content = leadingReferenceGroupColonRE.ReplaceAllString(content, "")
|
||||
content = spacedReferenceGroupRE.ReplaceAllString(content, " ")
|
||||
content = displayReferenceMarkerRE.ReplaceAllString(content, "")
|
||||
content = bareInternalSourceHandleRE.ReplaceAllString(content, "")
|
||||
content = spaceBeforeDisplayPunctuation.ReplaceAllString(content, "$1")
|
||||
content = spaceAfterDisplayColon.ReplaceAllString(content, "$1")
|
||||
return content
|
||||
|
||||
@@ -34,10 +34,20 @@ func TestSanitizeDisplayAnswerRemovesInternalReferenceMarkers(t *testing.T) {
|
||||
want: "Feature A remains supported.",
|
||||
},
|
||||
{
|
||||
name: "unrelated double bracket syntax is preserved",
|
||||
name: "unrelated double bracket syntax and ordinary link are preserved",
|
||||
input: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
|
||||
want: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
|
||||
},
|
||||
{
|
||||
name: "bare internal source handles",
|
||||
input: "Nova支持虚拟控制器离线仿真[d21],并可搭配VX500[w3]。",
|
||||
want: "Nova支持虚拟控制器离线仿真,并可搭配VX500。",
|
||||
},
|
||||
{
|
||||
name: "grouped bare internal source handles",
|
||||
input: "该结论由多个资料支持 [d21, c12,w3]。",
|
||||
want: "该结论由多个资料支持。",
|
||||
},
|
||||
{
|
||||
name: "content without references remains byte for byte unchanged",
|
||||
input: "Protocol: value ; keep spacing\n indented",
|
||||
|
||||
@@ -629,7 +629,7 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
|
||||
|
||||
func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) string {
|
||||
if h.outboundRewriter == nil {
|
||||
return sanitizeDisplayAnswer(content)
|
||||
return normalizeWideMarkdownTables(sanitizeDisplayAnswer(content))
|
||||
}
|
||||
timeout := h.finalDeliveryTimeout
|
||||
if timeout <= 0 {
|
||||
@@ -637,7 +637,7 @@ func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) st
|
||||
}
|
||||
rewriteCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
|
||||
defer cancel()
|
||||
return sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content))
|
||||
return normalizeWideMarkdownTables(sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content)))
|
||||
}
|
||||
|
||||
func (h *Handler) sanitizeOutboundPartial(content string) string {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// maxTableCellVisualWidth is the DingTalk-safe cell budget. CJK counts as 2
|
||||
// display columns; a cell wider than this tends to collapse the column layout
|
||||
// (one character per line) in the streaming card renderer.
|
||||
const maxTableCellVisualWidth = 32
|
||||
|
||||
// normalizeWideMarkdownTables makes final answers render reliably on DingTalk
|
||||
// cards without changing the knowledge itself. It (1) replaces <br> inside
|
||||
// table cells with a full-width separator, and (2) rewrites any table that
|
||||
// still contains an over-wide cell into a grouped list — a bold row heading
|
||||
// followed by one "- header:value" line per column. Compact tables pass
|
||||
// through unchanged. Tables inside code fences are never touched.
|
||||
func normalizeWideMarkdownTables(content string) string {
|
||||
if !strings.Contains(content, "|") {
|
||||
return content
|
||||
}
|
||||
lines := splitMarkdownStreamLines(content)
|
||||
out := make([]string, 0, len(lines))
|
||||
for i := 0; i < len(lines); {
|
||||
line := lines[i]
|
||||
if line.inFence {
|
||||
out = append(out, line.text)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if isTableHeaderCandidate(line.text) && i+1 < len(lines) &&
|
||||
isCompleteTableDelimiter(lines[i+1].text) {
|
||||
end := i + 1
|
||||
for end+1 < len(lines) && !lines[end+1].inFence &&
|
||||
strings.HasPrefix(strings.TrimSpace(lines[end+1].text), "|") {
|
||||
end++
|
||||
}
|
||||
out = append(out, normalizeTableBlock(lines[i:end+1])...)
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
out = append(out, line.text)
|
||||
i++
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
func normalizeTableBlock(rows []markdownStreamLine) []string {
|
||||
cells := make([][]string, 0, len(rows))
|
||||
for idx, row := range rows {
|
||||
if idx == 1 { // delimiter row
|
||||
continue
|
||||
}
|
||||
cells = append(cells, splitTableRow(row.text))
|
||||
}
|
||||
maxWidth := 0
|
||||
for _, row := range cells {
|
||||
for _, cell := range row {
|
||||
if w := visualWidth(cell); w > maxWidth {
|
||||
maxWidth = w
|
||||
}
|
||||
}
|
||||
}
|
||||
header := cells[0]
|
||||
if maxWidth <= maxTableCellVisualWidth {
|
||||
// Compact tables keep their exact original bytes unless a cell carries
|
||||
// <br>, which the card renderer mishandles inside cells.
|
||||
if !blockHasBreak(rows) {
|
||||
rebuilt := make([]string, len(rows))
|
||||
for i, row := range rows {
|
||||
rebuilt[i] = row.text
|
||||
}
|
||||
return rebuilt
|
||||
}
|
||||
var rebuilt []string
|
||||
for idx, row := range cells {
|
||||
joined := "| " + strings.Join(row, " | ") + " |"
|
||||
if idx == 0 {
|
||||
rebuilt = append(rebuilt, joined)
|
||||
delims := make([]string, len(row))
|
||||
for c := range row {
|
||||
delims[c] = "---"
|
||||
}
|
||||
rebuilt = append(rebuilt, "| "+strings.Join(delims, " | ")+" |")
|
||||
} else {
|
||||
rebuilt = append(rebuilt, joined)
|
||||
}
|
||||
}
|
||||
return rebuilt
|
||||
}
|
||||
|
||||
var out []string
|
||||
for gi, row := range cells[1:] {
|
||||
if gi > 0 {
|
||||
out = append(out, "")
|
||||
}
|
||||
var title string
|
||||
var rest []string
|
||||
for c, cell := range row {
|
||||
if c == 0 {
|
||||
title = cell
|
||||
continue
|
||||
}
|
||||
label := strings.TrimSpace(header[min(c, len(header)-1)])
|
||||
if label == "" {
|
||||
label = "第" + itoa(c+1) + "项"
|
||||
}
|
||||
if cell == "" {
|
||||
continue
|
||||
}
|
||||
rest = append(rest, "- "+label+":"+cell)
|
||||
}
|
||||
if title == "" {
|
||||
title = label2(row[0])
|
||||
}
|
||||
out = append(out, "**"+strings.Trim(title, "* ")+"**")
|
||||
out = append(out, rest...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func blockHasBreak(rows []markdownStreamLine) bool {
|
||||
for _, row := range rows {
|
||||
lower := strings.ToLower(row.text)
|
||||
if strings.Contains(lower, "<br") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitTableRow(line string) []string {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
trimmed = strings.TrimPrefix(trimmed, "|")
|
||||
trimmed = strings.TrimSuffix(trimmed, "|")
|
||||
parts := splitEscaped(trimmed)
|
||||
for i := range parts {
|
||||
cell := strings.TrimSpace(parts[i])
|
||||
cell = strings.ReplaceAll(cell, "<br>", ";")
|
||||
cell = strings.ReplaceAll(cell, "<br/>", ";")
|
||||
cell = strings.ReplaceAll(cell, "<br />", ";")
|
||||
cell = strings.ReplaceAll(cell, "\\|", "|")
|
||||
parts[i] = strings.TrimSpace(cell)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// splitEscaped splits on unescaped pipe separators.
|
||||
func splitEscaped(s string) []string {
|
||||
var parts []string
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch s[i] {
|
||||
case '\\':
|
||||
if i+1 < len(s) && s[i+1] == '|' {
|
||||
b.WriteString("\\|")
|
||||
i++
|
||||
continue
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
case '|':
|
||||
parts = append(parts, b.String())
|
||||
b.Reset()
|
||||
default:
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
parts = append(parts, b.String())
|
||||
return parts
|
||||
}
|
||||
|
||||
func label2(cell string) string { return strings.Trim(cell, "* ") }
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var d []byte
|
||||
for n > 0 {
|
||||
d = append([]byte{byte('0' + n%10)}, d...)
|
||||
n /= 10
|
||||
}
|
||||
return string(d)
|
||||
}
|
||||
|
||||
func visualWidth(s string) int {
|
||||
w := 0
|
||||
for _, r := range s {
|
||||
if r == '\n' {
|
||||
if w > 0 {
|
||||
w++ // separator counted conservatively
|
||||
}
|
||||
continue
|
||||
}
|
||||
if unicode.Is(unicode.Han, r) || unicode.Is(unicode.Katakana, r) ||
|
||||
unicode.Is(unicode.Hiragana, r) || fullwidthPunct(r) {
|
||||
w += 2
|
||||
continue
|
||||
}
|
||||
w++
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func fullwidthPunct(r rune) bool {
|
||||
switch r {
|
||||
case ',', '。', ':', ';', '(', ')', '、', '【', '】', '《', '》', '“', '”', '‘', '’', ' ':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeKeepsCompactTableUnchanged(t *testing.T) {
|
||||
content := "重量如下:\n\n| 产品型号 | 净重 (本体重量) | 毛重 (含包装) |\n| :--- | :--- | :--- |\n| CR3A | 16.5 kg / 17.5 kg | 28.5 kg |\n| CR5A | 25 kg | 37 kg |\n\n结束"
|
||||
got := normalizeWideMarkdownTables(content)
|
||||
if got != content {
|
||||
t.Fatalf("compact table must pass through untouched, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeConvertsWideTableToGroupedList(t *testing.T) {
|
||||
longCell := "J1: 180°/s (CR3A/5A/7A), 150°/s (CR10A/12A/16A)<br>J2: 180°/s (CR3A/5A/7A)"
|
||||
content := "差异如下:\n\n| 对比项 | CR 系列 | CRA 系列 |\n| :--- | :--- | :--- |\n| **关节最大速度** | " + longCell + " | J1: 180°/s (CR3A/5A/7A), 150°/s (CR10A/12A/16A)<br>J2: 更多参数说明 |\n| 控制柜 | CC162 | CC262 |\n\n结论"
|
||||
got := normalizeWideMarkdownTables(content)
|
||||
|
||||
if strings.Contains(got, "| 对比项") || strings.Contains(got, "<br>") {
|
||||
t.Fatalf("wide table must be converted and <br> removed, got %q", got)
|
||||
}
|
||||
if !strings.HasPrefix(got, "差异如下:\n\n") || !strings.HasSuffix(got, "\n\n结论") {
|
||||
t.Fatalf("surrounding text must be preserved, got %q", got)
|
||||
}
|
||||
// compact row (控制柜) from the same table is now a list entry too
|
||||
if !strings.Contains(got, "**关节最大速度**\n- CR 系列:") || !strings.Contains(got, "- CRA 系列:") {
|
||||
t.Fatalf("row key must become bold heading with header-labelled values, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "**控制柜**\n- CR 系列:CC162\n- CRA 系列:CC262") {
|
||||
t.Fatalf("every row must become a group, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "J1: 180°/s") == false {
|
||||
t.Fatalf("cell content must be preserved, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeReplacesBrKeepsTableWhenCellsShort(t *testing.T) {
|
||||
content := "| A | B |\n| --- | --- |\n| 短 | 行一<br>行二 |"
|
||||
got := normalizeWideMarkdownTables(content)
|
||||
if strings.Contains(got, "<br>") {
|
||||
t.Fatalf("<br> must be replaced, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "| A | B |") || !strings.Contains(got, "行一;行二") {
|
||||
t.Fatalf("short table must stay a table with ; separator, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSkipsFencedTables(t *testing.T) {
|
||||
long := strings.Repeat("长", 30)
|
||||
content := "```\n| 对比 | " + long + " |\n| --- | --- |\n| a | b |\n```\n正文"
|
||||
got := normalizeWideMarkdownTables(content)
|
||||
if got != content {
|
||||
t.Fatalf("fenced content must never be rewritten, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHandlesEscapedPipesAndEmptyHeaders(t *testing.T) {
|
||||
long := strings.Repeat("x", 40)
|
||||
content := "| 项 | | \n| --- | --- |\n| a\\|b | " + long + " |"
|
||||
got := normalizeWideMarkdownTables(content)
|
||||
if strings.Contains(got, "| a\\|b") {
|
||||
t.Fatalf("escaped pipe cell should be converted out of table form, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "a|b") {
|
||||
t.Fatalf("escaped pipe must be restored as literal, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "第2项:") {
|
||||
t.Fatalf("missing header should fall back to ordinal label, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -6,36 +6,11 @@ import (
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
)
|
||||
|
||||
// noAnswerPhrases lists explicit "no answer" phrases that the knowledge base
|
||||
// returns when it cannot find relevant information. These are matched as
|
||||
// substrings after trimming whitespace.
|
||||
var noAnswerPhrases = []string{
|
||||
"未找到",
|
||||
"没有找到",
|
||||
"无法确定",
|
||||
"暂无相关",
|
||||
"暂无已发布的权威知识",
|
||||
"知识库中没有",
|
||||
"产品不存在于资料中",
|
||||
}
|
||||
|
||||
// retrievalGapPhrases are only considered a no-answer signal when the answer
|
||||
// also directs the user to a human channel. Requiring both signals avoids
|
||||
// misclassifying valid negative results such as "未查询到异常记录".
|
||||
var retrievalGapPhrases = []string{
|
||||
"未查询到",
|
||||
"未检索到",
|
||||
"未能查询到",
|
||||
"未能检索到",
|
||||
}
|
||||
|
||||
var humanEscalationPhrases = []string{
|
||||
"建议您联系",
|
||||
"建议联系",
|
||||
"请联系",
|
||||
"联系技术支持",
|
||||
"联系官方销售",
|
||||
}
|
||||
// canonicalNoAnswer is the only response that carries the explicit-no-answer
|
||||
// machine contract. It must be the complete answer, never a substring match:
|
||||
// an otherwise supported answer may legitimately say that one specific detail
|
||||
// was not found.
|
||||
const canonicalNoAnswer = "当前暂无已发布的权威知识,暂不建议操作。"
|
||||
|
||||
// Evaluator classifies a successful turn into a coverage verdict.
|
||||
type Evaluator struct{}
|
||||
@@ -53,17 +28,20 @@ func (e *Evaluator) Version() string {
|
||||
// Evaluate classifies a turn. It rejects non-succeeded turns. The evaluation
|
||||
// rules (in priority order):
|
||||
// 1. Failed/processing turns → ErrTurnNotSucceeded
|
||||
// 2. Answer contains an explicit "no answer" phrase → uncovered_explicit_no_answer
|
||||
// 3. Image turns (image media present) are classified by whether the question
|
||||
// 2. Has references → covered_has_references (references are the coverage
|
||||
// evidence; wording in a supported answer never overrides them)
|
||||
// 3. Complete canonical no-answer response with no references
|
||||
// → uncovered_explicit_no_answer
|
||||
// 4. Image turns (image media present) are classified by whether the question
|
||||
// actually triggered a knowledge-base search:
|
||||
// - has references → covered_has_references
|
||||
// - 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
|
||||
// 5. Has references → covered_has_references
|
||||
// 5. 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
|
||||
//
|
||||
// v1 never uses a score threshold. TopScore captures the highest reference score
|
||||
// v5 never uses a score threshold. TopScore captures the highest reference score
|
||||
// (0 if no references).
|
||||
func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
|
||||
if turn.Status != "succeeded" {
|
||||
@@ -73,9 +51,25 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
|
||||
// Normalize answer for no-answer phrase detection.
|
||||
trimmed := strings.TrimSpace(turn.Answer)
|
||||
|
||||
// Check for explicit no-answer phrases first — this takes priority
|
||||
// over reference presence.
|
||||
if isNoAnswer(trimmed) {
|
||||
// References are the durable coverage evidence. A supported answer may
|
||||
// correctly note that one sub-detail is absent, so its wording cannot turn
|
||||
// the entire turn into a knowledge gap.
|
||||
if len(turn.References) > 0 {
|
||||
return Decision{
|
||||
TurnID: turn.ID,
|
||||
TenantKey: turn.TenantKey,
|
||||
Channel: turn.Channel,
|
||||
EvaluatorVersion: EvaluatorVersion,
|
||||
Verdict: VerdictCovered,
|
||||
ReasonCode: ReasonCoveredHasReferences,
|
||||
ReferenceCount: len(turn.References),
|
||||
TopScore: maxScore(turn.References),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// An explicit abstention is a stable machine contract only when it is the
|
||||
// complete answer. Do not use broad natural-language substring matching.
|
||||
if isCanonicalNoAnswer(trimmed) {
|
||||
return Decision{
|
||||
TurnID: turn.ID,
|
||||
TenantKey: turn.TenantKey,
|
||||
@@ -83,8 +77,8 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
|
||||
EvaluatorVersion: EvaluatorVersion,
|
||||
Verdict: VerdictUncovered,
|
||||
ReasonCode: ReasonUncoveredExplicitNoAnswer,
|
||||
ReferenceCount: len(turn.References),
|
||||
TopScore: maxScore(turn.References),
|
||||
ReferenceCount: 0,
|
||||
TopScore: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -126,8 +120,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,
|
||||
@@ -163,23 +171,8 @@ func hasImageMedia(media []core.MediaAudit) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func isNoAnswer(answer string) bool {
|
||||
lower := strings.ToLower(answer)
|
||||
for _, phrase := range noAnswerPhrases {
|
||||
if strings.Contains(lower, strings.ToLower(phrase)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return containsAny(lower, retrievalGapPhrases) && containsAny(lower, humanEscalationPhrases)
|
||||
}
|
||||
|
||||
func containsAny(value string, phrases []string) bool {
|
||||
for _, phrase := range phrases {
|
||||
if strings.Contains(value, strings.ToLower(phrase)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
func isCanonicalNoAnswer(answer string) bool {
|
||||
return strings.TrimSpace(answer) == canonicalNoAnswer
|
||||
}
|
||||
|
||||
func maxScore(refs []core.Reference) float64 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() != "v5" {
|
||||
t.Errorf("Evaluator version = %q, want v5", e.Version())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,65 +30,51 @@ func TestEvaluator_RejectsProcessingTurn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_UncoveredExplicitNoAnswer(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
noAnswerPhrases := []string{
|
||||
"未找到相关结果",
|
||||
"没有找到你需要的信息",
|
||||
"无法确定该问题的答案",
|
||||
"暂无相关信息",
|
||||
"知识库中没有找到相关内容",
|
||||
"这个问题知识库中没有可用的信息",
|
||||
}
|
||||
for _, answer := range noAnswerPhrases {
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: answer,
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.9},
|
||||
},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for answer %q: %v", answer, err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered {
|
||||
t.Errorf("answer %q: verdict = %s, want uncovered", answer, d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
|
||||
t.Errorf("answer %q: reason_code = %s, want %s", answer, d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_ProductionMissingProductPhraseTrumpsReferences(t *testing.T) {
|
||||
func TestEvaluator_UncoveredCanonicalNoAnswerWithoutReferences(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "missing-product-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "根据提供的检索信息,无法提供对应手册。原因:产品不存在于资料中;检索资料未提及该产品。建议联系技术支持部门。",
|
||||
References: []core.Reference{
|
||||
{ID: "unrelated-1", Score: 0.395940},
|
||||
{ID: "unrelated-2", Score: 0.311595},
|
||||
},
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: canonicalNoAnswer,
|
||||
KnowledgeSearched: true,
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered || d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
|
||||
t.Fatalf("verdict=%s reason=%s", d.Verdict, d.ReasonCode)
|
||||
t.Fatalf("verdict/reason = %s/%s, want %s/%s", d.Verdict, d.ReasonCode, VerdictUncovered, ReasonUncoveredExplicitNoAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_NovaPartialNotFoundWithSevenReferencesIsCovered(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "nova-partial-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "已找到Nova系列的相关资料;未找到VX500 2.5D视觉一体方案的明确说明。",
|
||||
References: []core.Reference{
|
||||
{ID: "r1"}, {ID: "r2"}, {ID: "r3"}, {ID: "r4"},
|
||||
{ID: "r5"}, {ID: "r6"}, {ID: "r7"},
|
||||
},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Verdict != VerdictCovered || d.ReasonCode != ReasonCoveredHasReferences || d.ReferenceCount != 7 {
|
||||
t.Fatalf("verdict/reason/references = %s/%s/%d, want %s/%s/7", d.Verdict, d.ReasonCode, d.ReferenceCount, VerdictCovered, ReasonCoveredHasReferences)
|
||||
}
|
||||
}
|
||||
|
||||
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 +91,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 {
|
||||
@@ -203,9 +190,9 @@ func TestEvaluator_TopScoreZeroWhenNoReferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_NoAnswerTrumpsReferences(t *testing.T) {
|
||||
// When answer contains explicit "no answer" phrase, it should be uncovered
|
||||
// even if references exist. No-answer detection takes priority.
|
||||
func TestEvaluator_ReferencesTrumpNaturalLanguageNoAnswer(t *testing.T) {
|
||||
// References are durable coverage evidence; a natural-language phrase must
|
||||
// not override them.
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
@@ -220,11 +207,11 @@ func TestEvaluator_NoAnswerTrumpsReferences(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered {
|
||||
t.Errorf("no-answer with references: verdict = %s, want uncovered", d.Verdict)
|
||||
if d.Verdict != VerdictCovered {
|
||||
t.Errorf("answer with references: verdict = %s, want covered", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
|
||||
t.Errorf("no-answer with references: reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
|
||||
if d.ReasonCode != ReasonCoveredHasReferences {
|
||||
t.Errorf("answer with references: reason_code = %s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,12 +230,12 @@ func TestEvaluator_NoAnswerPhraseMatchesInsideAnswer(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
|
||||
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
|
||||
if d.ReasonCode != ReasonCoveredHasReferences {
|
||||
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_ProductionUnavailableAuthorityPhraseTrumpsReferences(t *testing.T) {
|
||||
func TestEvaluator_ProductionUnavailableAuthorityPhraseWithReferencesIsCovered(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "cra-price-turn",
|
||||
@@ -263,11 +250,11 @@ func TestEvaluator_ProductionUnavailableAuthorityPhraseTrumpsReferences(t *testi
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered {
|
||||
t.Fatalf("verdict = %s, want %s", d.Verdict, VerdictUncovered)
|
||||
if d.Verdict != VerdictCovered {
|
||||
t.Fatalf("verdict = %s, want %s", d.Verdict, VerdictCovered)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
|
||||
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
|
||||
if d.ReasonCode != ReasonCoveredHasReferences {
|
||||
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,10 +267,10 @@ func TestEvaluator_RetrievalGapRequiresHumanEscalation(t *testing.T) {
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "production NOVA price answer",
|
||||
name: "retrieval wording with evidence remains covered",
|
||||
answer: "当前暂未查询到Nova系列机器人的公开销售价格信息。\n\n如需获取报价,建议您联系越疆科技官方销售渠道或对应区域的销售工程师咨询具体价格方案。",
|
||||
verdict: VerdictUncovered,
|
||||
reason: ReasonUncoveredExplicitNoAnswer,
|
||||
verdict: VerdictCovered,
|
||||
reason: ReasonCoveredHasReferences,
|
||||
},
|
||||
{
|
||||
name: "retrieval gap without escalation is a valid negative result",
|
||||
|
||||
@@ -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)
|
||||
@@ -103,14 +104,12 @@ func TestService_EvaluateAndPersist_UncoveredExplicitNoAnswer(t *testing.T) {
|
||||
s, _ := NewService(repo)
|
||||
|
||||
turn := core.Turn{
|
||||
ID: "turn-3",
|
||||
TenantKey: "t1",
|
||||
Channel: "dingtalk",
|
||||
Status: "succeeded",
|
||||
Answer: "未找到相关结果",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.5},
|
||||
},
|
||||
ID: "turn-3",
|
||||
TenantKey: "t1",
|
||||
Channel: "dingtalk",
|
||||
Status: "succeeded",
|
||||
Answer: canonicalNoAnswer,
|
||||
KnowledgeSearched: true,
|
||||
}
|
||||
|
||||
d, err := s.EvaluateAndPersist(context.Background(), turn)
|
||||
|
||||
@@ -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 = "v5"
|
||||
|
||||
var (
|
||||
ErrTurnNotSucceeded = errors.New("coverage evaluator: turn status must be succeeded")
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package qa
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLocalizedQueryPreservesOriginalWhenAuto(t *testing.T) {
|
||||
if got := localizedQuery("原始问题", "auto"); got != "原始问题" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedQueryAddsRequestedLanguageWithoutChangingInput(t *testing.T) {
|
||||
original := "机械臂怎么复位?"
|
||||
got := localizedQuery(original, "ja")
|
||||
if got != "日本語で回答してください。\n\n"+original {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if original != "机械臂怎么复位?" {
|
||||
t.Fatal("original query changed")
|
||||
}
|
||||
}
|
||||
+6
-19
@@ -227,7 +227,7 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
|
||||
return s.weknora.UpdateSessionTitle(updateCtx, existingSessionID, title, "Managed by DobotAI; business state remains outside WeKnora")
|
||||
})
|
||||
|
||||
query := localizedQuery(request.Query, request.ResponseLocale)
|
||||
query := request.Query
|
||||
if request.ConversationType == core.ConversationGroup && strings.TrimSpace(request.SenderName) != "" {
|
||||
query = request.SenderName + ": " + query
|
||||
}
|
||||
@@ -238,6 +238,11 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
|
||||
chatRequest := weknora.AgentChatRequest{
|
||||
Query: query, AgentID: executionAgentID, AgentEnabled: true, Channel: "im", EnableMemory: true,
|
||||
Images: images,
|
||||
// The language preference travels as an Accept-Language request
|
||||
// header (see weknora.resolveLocale), never as prompt text: WeKnora's
|
||||
// system prompt enforces "ALWAYS respond in {{language}}" which is
|
||||
// fed by that header, so a prompt-prefix instruction loses to it.
|
||||
Locale: strings.TrimSpace(request.ResponseLocale),
|
||||
}
|
||||
// Track whether this turn actually triggered a knowledge-base search by
|
||||
// observing knowledge_search tool-call events. The flag feeds coverage
|
||||
@@ -373,24 +378,6 @@ func isKnowledgeTool(toolName string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func localizedQuery(query, locale string) string {
|
||||
query = strings.TrimSpace(query)
|
||||
switch strings.TrimSpace(locale) {
|
||||
case "zh-CN":
|
||||
return "请使用简体中文回答。\n\n" + query
|
||||
case "en":
|
||||
return "Answer in English.\n\n" + query
|
||||
case "ja":
|
||||
return "日本語で回答してください。\n\n" + query
|
||||
case "ko":
|
||||
return "한국어로 답변해 주세요.\n\n" + query
|
||||
case "es":
|
||||
return "Responde en español.\n\n" + query
|
||||
default:
|
||||
return query
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRequestIdentity(request core.QARequest) core.QARequest {
|
||||
request.TenantKey = strings.TrimSpace(request.TenantKey)
|
||||
request.Channel = strings.TrimSpace(request.Channel)
|
||||
|
||||
@@ -49,11 +49,28 @@ type Service struct {
|
||||
repo Repository
|
||||
quickAgentID string
|
||||
reasoningAgentID string
|
||||
defaultMode Mode
|
||||
now func() time.Time
|
||||
idleTimeout time.Duration
|
||||
}
|
||||
|
||||
func NewService(repo Repository, quickAgentID, reasoningAgentID string) (*Service, error) {
|
||||
// Option customises a reasoning mode Service.
|
||||
type Option func(*Service) error
|
||||
|
||||
// WithDefaultMode sets the mode applied to users who never explicitly chose
|
||||
// one with /think. Passing ModeQuick (the default when unset) preserves the
|
||||
// historical behaviour of answering with the quick agent.
|
||||
func WithDefaultMode(mode Mode) Option {
|
||||
return func(s *Service) error {
|
||||
if mode != ModeQuick && mode != ModeReasoning {
|
||||
return fmt.Errorf("invalid default reasoning mode %q", mode)
|
||||
}
|
||||
s.defaultMode = mode
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewService(repo Repository, quickAgentID, reasoningAgentID string, opts ...Option) (*Service, error) {
|
||||
quickAgentID = strings.TrimSpace(quickAgentID)
|
||||
reasoningAgentID = strings.TrimSpace(reasoningAgentID)
|
||||
if repo == nil {
|
||||
@@ -65,13 +82,29 @@ func NewService(repo Repository, quickAgentID, reasoningAgentID string) (*Servic
|
||||
if quickAgentID == reasoningAgentID {
|
||||
return nil, errors.New("quick and reasoning agent IDs must differ")
|
||||
}
|
||||
return &Service{
|
||||
service := &Service{
|
||||
repo: repo,
|
||||
quickAgentID: quickAgentID,
|
||||
reasoningAgentID: reasoningAgentID,
|
||||
defaultMode: ModeQuick,
|
||||
now: time.Now,
|
||||
idleTimeout: defaultIdleTimeout,
|
||||
}, nil
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if err := opt(service); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// effectivelyReasoning reports whether a turn should be served by the
|
||||
// reasoning agent given the resolved state and the configured default mode.
|
||||
func (s *Service) effectivelyReasoning(state State) bool {
|
||||
if state.Mode == ModeReasoning {
|
||||
return true
|
||||
}
|
||||
return !state.UserSelected && s.defaultMode == ModeReasoning
|
||||
}
|
||||
|
||||
func (s *Service) Prepare(ctx context.Context, scope Scope, text string) (Decision, error) {
|
||||
@@ -95,10 +128,14 @@ func (s *Service) Prepare(ctx context.Context, scope Scope, text string) (Decisi
|
||||
state.MaxRounds,
|
||||
)}, nil
|
||||
case CommandDisable:
|
||||
_, wasReasoning, err := s.repo.Disable(ctx, scope, s.now())
|
||||
previous, _, err := s.repo.ResolveState(ctx, scope, s.now())
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
wasReasoning := s.effectivelyReasoning(previous)
|
||||
if _, _, err = s.repo.Disable(ctx, scope, s.now()); err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
if !wasReasoning {
|
||||
return Decision{Handled: true, Reply: "当前未开启推理模式,默认是快速问答模式。"}, nil
|
||||
}
|
||||
@@ -106,7 +143,7 @@ func (s *Service) Prepare(ctx context.Context, scope Scope, text string) (Decisi
|
||||
case CommandSingle:
|
||||
return Decision{Query: command.Query, AgentID: s.reasoningAgentID, Execution: ExecutionReasoningSingle}, nil
|
||||
case CommandHelp:
|
||||
return Decision{Handled: true, Reply: helpReply()}, nil
|
||||
return Decision{Handled: true, Reply: helpReply(s.defaultMode)}, nil
|
||||
default:
|
||||
return Decision{Handled: true, Reply: "用法:/think <问题>、/think on、/think off。"}, nil
|
||||
}
|
||||
@@ -119,13 +156,25 @@ func (s *Service) Prepare(ctx context.Context, scope Scope, text string) (Decisi
|
||||
if state.Mode == ModeReasoning {
|
||||
return Decision{Query: query, AgentID: s.reasoningAgentID, Execution: ExecutionReasoning}, nil
|
||||
}
|
||||
// The user never chose a mode explicitly: follow the operator default.
|
||||
// Default-mode reasoning runs as a single reasoning turn so it never
|
||||
// enters the rounds/idle-expiry machinery; the user can pin quick mode
|
||||
// any time with /think off.
|
||||
if s.effectivelyReasoning(state) {
|
||||
return Decision{Query: query, AgentID: s.reasoningAgentID, Execution: ExecutionReasoningSingle}, nil
|
||||
}
|
||||
return Decision{Query: query, AgentID: s.quickAgentID, Execution: ExecutionQuick, Expired: expired}, nil
|
||||
}
|
||||
|
||||
func helpReply() string {
|
||||
func helpReply(defaultMode Mode) string {
|
||||
defaultLabel := "默认模式:快速问答模式,基于知识库快速准确作答,响应快、成本低,适合信息查询和事实确认。\n" +
|
||||
"智能推理模式:支持多步思考与工具调用,计费标准更高,适合复杂分析和推导。\n"
|
||||
if defaultMode == ModeReasoning {
|
||||
defaultLabel = "默认模式:智能推理模式,支持多步思考与工具调用,效果更强、计费标准更高。\n" +
|
||||
"快速问答模式:基于知识库快速准确作答,响应快、成本低,可用 /think off 切换并保持。\n"
|
||||
}
|
||||
return "一、模式说明\n" +
|
||||
"默认模式:快速问答模式,基于知识库快速准确作答,响应快、成本低,适合信息查询和事实确认。\n" +
|
||||
"智能推理模式:支持多步思考与工具调用,计费标准更高,适合复杂分析和推导。\n\n" +
|
||||
defaultLabel + "\n" +
|
||||
"二、模式切换指令\n" +
|
||||
"/think + 你的问题:仅当前问题使用智能推理\n" +
|
||||
"/think on:开启持续智能推理模式\n" +
|
||||
|
||||
@@ -23,6 +23,7 @@ func (f *fakeRepository) ResolveState(context.Context, Scope, time.Time) (State,
|
||||
func (f *fakeRepository) Enable(context.Context, Scope, time.Time, time.Duration) (State, error) {
|
||||
f.enableCalls++
|
||||
f.state.Mode = ModeReasoning
|
||||
f.state.UserSelected = true
|
||||
f.state.RoundsUsed = 0
|
||||
return f.state, nil
|
||||
}
|
||||
@@ -31,6 +32,7 @@ func (f *fakeRepository) Disable(context.Context, Scope, time.Time) (State, bool
|
||||
f.disableCalls++
|
||||
wasReasoning := f.state.Mode == ModeReasoning
|
||||
f.state.Mode = ModeQuick
|
||||
f.state.UserSelected = true
|
||||
f.state.RoundsUsed = 0
|
||||
return f.state, wasReasoning, nil
|
||||
}
|
||||
@@ -177,3 +179,87 @@ func TestPrepareReturnsLocalHelpWithoutStateMutation(t *testing.T) {
|
||||
t.Fatalf("help mutated mode: %+v", repo)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestServiceWithDefault(t *testing.T, repo Repository, mode Mode) *Service {
|
||||
t.Helper()
|
||||
service, err := NewService(repo, "builtin-quick-answer", "builtin-smart-reasoning", WithDefaultMode(mode))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.now = func() time.Time { return time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) }
|
||||
return service
|
||||
}
|
||||
|
||||
func TestNewServiceRejectsInvalidDefaultMode(t *testing.T) {
|
||||
if _, err := NewService(&fakeRepository{}, "q", "r", WithDefaultMode("turbo")); err == nil {
|
||||
t.Fatal("expected invalid default mode to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareDefaultReasoningRoutesUnselectedUserToReasoningSingle(t *testing.T) {
|
||||
repo := &fakeRepository{state: DefaultState()}
|
||||
decision, err := newTestServiceWithDefault(t, repo, ModeReasoning).Prepare(context.Background(), testScope(), "普通问题")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decision.AgentID != "builtin-smart-reasoning" || decision.Execution != ExecutionReasoningSingle {
|
||||
t.Fatalf("decision=%+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareDefaultReasoningHonoursUserQuickSelection(t *testing.T) {
|
||||
state := DefaultState()
|
||||
state.UserSelected = true
|
||||
decision, err := newTestServiceWithDefault(t, &fakeRepository{state: state}, ModeReasoning).
|
||||
Prepare(context.Background(), testScope(), "普通问题")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decision.AgentID != "builtin-quick-answer" || decision.Execution != ExecutionQuick {
|
||||
t.Fatalf("decision=%+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareDefaultQuickKeepsLegacyBehaviour(t *testing.T) {
|
||||
decision, err := newTestServiceWithDefault(t, &fakeRepository{state: DefaultState()}, ModeQuick).
|
||||
Prepare(context.Background(), testScope(), "普通问题")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decision.AgentID != "builtin-quick-answer" || decision.Execution != ExecutionQuick {
|
||||
t.Fatalf("decision=%+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkOffPinsQuickModeUnderReasoningDefault(t *testing.T) {
|
||||
repo := &fakeRepository{state: DefaultState()}
|
||||
service := newTestServiceWithDefault(t, repo, ModeReasoning)
|
||||
decision, err := service.Prepare(context.Background(), testScope(), "/think off")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !decision.Handled || !strings.Contains(decision.Reply, "切回") {
|
||||
t.Fatalf("decision=%+v", decision)
|
||||
}
|
||||
if !repo.state.UserSelected || repo.state.Mode != ModeQuick {
|
||||
t.Fatalf("state=%+v", repo.state)
|
||||
}
|
||||
followUp, err := service.Prepare(context.Background(), testScope(), "后续问题")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if followUp.AgentID != "builtin-quick-answer" {
|
||||
t.Fatalf("follow-up escaped user quick selection: %+v", followUp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpReflectsReasoningDefaultMode(t *testing.T) {
|
||||
decision, err := newTestServiceWithDefault(t, &fakeRepository{state: DefaultState()}, ModeReasoning).
|
||||
Prepare(context.Background(), testScope(), "/help")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(decision.Reply, "默认模式:智能推理模式") {
|
||||
t.Fatalf("help=%q", decision.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ var ErrInvalidState = errors.New("invalid reasoning mode state")
|
||||
|
||||
type State struct {
|
||||
Mode Mode
|
||||
UserSelected bool
|
||||
MaxRounds int
|
||||
RoundsUsed int
|
||||
HintEnabled bool
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ func (s *Store) Enable(ctx context.Context, scope reasoningmode.Scope, now time.
|
||||
return reasoningmode.State{}, err
|
||||
}
|
||||
state.Mode = reasoningmode.ModeReasoning
|
||||
state.UserSelected = true
|
||||
state.RoundsUsed = 0
|
||||
state.LastActivityAt = now
|
||||
state.ExpiresAt = now.Add(idle)
|
||||
@@ -92,6 +93,7 @@ func (s *Store) Disable(ctx context.Context, scope reasoningmode.Scope, now time
|
||||
state, _ = reasoningmode.EffectiveState(state, now)
|
||||
wasReasoning := state.Mode == reasoningmode.ModeReasoning
|
||||
state.Mode = reasoningmode.ModeQuick
|
||||
state.UserSelected = true
|
||||
state.RoundsUsed = 0
|
||||
state.ExpiresAt = time.Time{}
|
||||
if err = updateReasoningState(ctx, tx, scope, state, now); err != nil {
|
||||
@@ -256,14 +258,14 @@ func ensureReasoningState(ctx context.Context, tx pgx.Tx, scope reasoningmode.Sc
|
||||
}
|
||||
|
||||
func selectReasoningStateForUpdate(ctx context.Context, tx pgx.Tx, scope reasoningmode.Scope) (reasoningmode.State, error) {
|
||||
return scanReasoningState(tx.QueryRow(ctx, `SELECT mode,max_rounds,rounds_used,hint_enabled,last_activity_at,expires_at,onboarding_shown_at
|
||||
return scanReasoningState(tx.QueryRow(ctx, `SELECT mode,user_selected,max_rounds,rounds_used,hint_enabled,last_activity_at,expires_at,onboarding_shown_at
|
||||
FROM reasoning_mode_states
|
||||
WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3
|
||||
FOR UPDATE`, scope.TenantKey, scope.Channel, scope.ExternalActorID))
|
||||
}
|
||||
|
||||
func selectReasoningState(ctx context.Context, tx pgx.Tx, scope reasoningmode.Scope) (reasoningmode.State, error) {
|
||||
return scanReasoningState(tx.QueryRow(ctx, `SELECT mode,max_rounds,rounds_used,hint_enabled,last_activity_at,expires_at,onboarding_shown_at
|
||||
return scanReasoningState(tx.QueryRow(ctx, `SELECT mode,user_selected,max_rounds,rounds_used,hint_enabled,last_activity_at,expires_at,onboarding_shown_at
|
||||
FROM reasoning_mode_states
|
||||
WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3`, scope.TenantKey, scope.Channel, scope.ExternalActorID))
|
||||
}
|
||||
@@ -276,7 +278,7 @@ func scanReasoningState(row pgx.Row) (reasoningmode.State, error) {
|
||||
expires pgtype.Timestamptz
|
||||
onboarding pgtype.Timestamptz
|
||||
)
|
||||
if err := row.Scan(&mode, &state.MaxRounds, &state.RoundsUsed, &state.HintEnabled, &last, &expires, &onboarding); err != nil {
|
||||
if err := row.Scan(&mode, &state.UserSelected, &state.MaxRounds, &state.RoundsUsed, &state.HintEnabled, &last, &expires, &onboarding); err != nil {
|
||||
return reasoningmode.State{}, fmt.Errorf("scan reasoning state: %w", err)
|
||||
}
|
||||
state.Mode = reasoningmode.Mode(mode)
|
||||
@@ -295,11 +297,11 @@ func scanReasoningState(row pgx.Row) (reasoningmode.State, error) {
|
||||
|
||||
func updateReasoningState(ctx context.Context, tx pgx.Tx, scope reasoningmode.Scope, state reasoningmode.State, now time.Time) error {
|
||||
_, err := tx.Exec(ctx, `UPDATE reasoning_mode_states SET
|
||||
mode=$4,max_rounds=$5,rounds_used=$6,hint_enabled=$7,
|
||||
last_activity_at=$8,expires_at=$9,onboarding_shown_at=$10,updated_at=$11
|
||||
mode=$4,user_selected=$5,max_rounds=$6,rounds_used=$7,hint_enabled=$8,
|
||||
last_activity_at=$9,expires_at=$10,onboarding_shown_at=$11,updated_at=$12
|
||||
WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3`,
|
||||
scope.TenantKey, scope.Channel, scope.ExternalActorID,
|
||||
string(state.Mode), state.MaxRounds, state.RoundsUsed, state.HintEnabled,
|
||||
string(state.Mode), state.UserSelected, state.MaxRounds, state.RoundsUsed, state.HintEnabled,
|
||||
nullableTime(state.LastActivityAt), nullableTime(state.ExpiresAt), nullableTime(state.OnboardingShownAt), now,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -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) == "" {
|
||||
|
||||
@@ -54,6 +54,12 @@ type AgentChatRequest struct {
|
||||
DisableTitle bool `json:"disable_title,omitempty"`
|
||||
KnowledgeBaseIDs []string `json:"knowledge_base_ids,omitempty"`
|
||||
Images []Image `json:"images,omitempty"`
|
||||
// Locale is the response language preference ("zh-CN", "en", "ja", "ko",
|
||||
// "es", or "auto"). It is transmitted as the Accept-Language request
|
||||
// header — WeKnora's Language middleware turns it into the {{language}}
|
||||
// prompt placeholder. "auto" and "" mean: detect from the query text.
|
||||
// Not serialized in the JSON body.
|
||||
Locale string `json:"-"`
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
@@ -148,6 +154,9 @@ func (c *Client) agentChat(ctx context.Context, sessionID string, request AgentC
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.Header.Set("X-API-Key", c.apiKey)
|
||||
if locale := resolveLocale(request.Locale, request.Query); locale != "" {
|
||||
req.Header.Set("Accept-Language", locale)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return ChatResult{}, fmt.Errorf("call weknora agent chat: %w", err)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package weknora
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// fixedLocales are the response languages the platform understands end-to-end
|
||||
// (settings command, preference storage, and WeKnora's LanguageLocaleName map).
|
||||
var fixedLocales = map[string]bool{
|
||||
"zh-CN": true, "en": true, "ja": true, "ko": true, "es": true,
|
||||
}
|
||||
|
||||
// resolveLocale maps a user preference + query text to an Accept-Language
|
||||
// value understood by WeKnora's Language middleware.
|
||||
//
|
||||
// - "" -> "" (send no header; WeKnora keeps its own fallback)
|
||||
// - "auto" -> best-effort detection from the query script
|
||||
// - a known fixed locale (e.g. "en", "ja") -> passed through verbatim
|
||||
//
|
||||
// WeKnora matches the first Accept-Language tag and maps it to a human
|
||||
// language name for its prompts, so the returned tag must be one it knows
|
||||
// ("zh-CN", "en", "ja", "ko", "es", ...). An "auto" query whose script
|
||||
// cannot be classified returns "" so the server behaves exactly as before.
|
||||
func resolveLocale(locale, query string) string {
|
||||
locale = strings.TrimSpace(locale)
|
||||
if fixedLocales[locale] {
|
||||
return locale
|
||||
}
|
||||
if locale == "auto" {
|
||||
return detectLocale(query)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// detectLocale classifies a query by the dominant non-ASCII script. Latin
|
||||
// text maps to "en"; CJK ideographs to "zh-CN"; kana to "ja"; hangul to "ko";
|
||||
// Spanish is not reliably script-detectable and is never auto-selected.
|
||||
// Pure punctuation/digits/empty text return "" (no guess).
|
||||
func detectLocale(query string) string {
|
||||
var cjk, kana, hangul, latin int
|
||||
for _, r := range query {
|
||||
switch {
|
||||
case unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r):
|
||||
kana++
|
||||
case unicode.Is(unicode.Han, r):
|
||||
cjk++
|
||||
case unicode.Is(unicode.Hangul, r):
|
||||
hangul++
|
||||
case r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z':
|
||||
latin++
|
||||
}
|
||||
}
|
||||
// Japanese mixes kanji in, so any kana presence wins over Han count.
|
||||
switch {
|
||||
case kana > 0:
|
||||
return "ja"
|
||||
case hangul > 0:
|
||||
return "ko"
|
||||
case cjk > 0:
|
||||
return "zh-CN"
|
||||
case latin > 0:
|
||||
return "en"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package weknora
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveLocaleFixedPrefersPreference(t *testing.T) {
|
||||
// A fixed preference must win over the query's own script.
|
||||
for _, locale := range []string{"zh-CN", "en", "ja", "ko", "es"} {
|
||||
if got := resolveLocale(locale, "how many CR series do we have?"); got != locale {
|
||||
t.Fatalf("resolveLocale(%q) = %q, want %q", locale, got, locale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLocaleAutoDetectsFromQuery(t *testing.T) {
|
||||
cases := []struct{ query, want string }{
|
||||
{"how many serise do we have for CR?", "en"},
|
||||
{"cr一共有几个系列", "zh-CN"},
|
||||
{"ロボットのアームリセット方法", "ja"},
|
||||
{"机器人复位怎么操作", "zh-CN"},
|
||||
{"로봇 암 리셋 방법", "ko"},
|
||||
{"5013", ""}, // no script evidence: no guess
|
||||
{"!!! ??", ""}, // punctuation only: no guess
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := resolveLocale("auto", tc.query); got != tc.want {
|
||||
t.Fatalf("resolveLocale(auto, %q) = %q, want %q", tc.query, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLocaleUnknownPreferenceSendsNothing(t *testing.T) {
|
||||
// Empty and unrecognized locales must not fabricate a header; WeKnora
|
||||
// keeps its own fallback behaviour, exactly like before this feature.
|
||||
if got := resolveLocale("", "hello"); got != "" {
|
||||
t.Fatalf("empty locale = %q, want none", got)
|
||||
}
|
||||
if got := resolveLocale("fr-CA", "hello"); got != "" {
|
||||
t.Fatalf("unknown locale = %q, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentChatSendsAcceptLanguageHeader(t *testing.T) {
|
||||
var gotHeader string
|
||||
body := "data: {\"response_type\":\"answer\",\"content\":\"ok\"}\n\ndata: {\"response_type\":\"complete\",\"done\":true}\n\n"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotHeader = r.Header.Get("Accept-Language")
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := NewClient(server.URL, "test-key", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.AgentChat(context.Background(), "s1", AgentChatRequest{
|
||||
Query: "how many serise do we have for CR?", AgentID: "a1", Locale: "en",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotHeader != "en" {
|
||||
t.Fatalf("Accept-Language = %q, want %q", gotHeader, "en")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentChatOmitsAcceptLanguageWhenNoPreference(t *testing.T) {
|
||||
var sent bool
|
||||
body := "data: {\"response_type\":\"answer\",\"content\":\"ok\"}\n\ndata: {\"response_type\":\"complete\",\"done\":true}\n\n"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, sent = r.Header["Accept-Language"]
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, _ := NewClient(server.URL, "test-key", 0)
|
||||
if _, err := client.AgentChat(context.Background(), "s1", AgentChatRequest{
|
||||
Query: "cr有哪些系列", AgentID: "a1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sent {
|
||||
t.Fatal("Accept-Language header must be absent without a locale")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentChatRequestLocaleIsNotSerialized(t *testing.T) {
|
||||
// Locale rides on the header only; the JSON body contract is unchanged.
|
||||
// (Guards against a future refactor accidentally adding it to the payload.)
|
||||
if got := resolveLocale("auto", "How to reset the robot arm?"); got != "en" {
|
||||
t.Fatalf("auto detection broken: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package scripts
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDockerDeploymentContainsOnlyDobotAIApplication(t *testing.T) {
|
||||
compose, err := os.ReadFile("../deploy/dobotai-dingtalk/compose.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(compose)
|
||||
for _, required := range []string{"dobotai-dingtalk:", "container_name: dobotai-dingtalk", "network_mode: host", "env_file:", "read_only: true"} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("compose missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"dobotai-postgres", "postgres-data", "name: dobotai-network"} {
|
||||
if strings.Contains(source, forbidden) {
|
||||
t.Errorf("compose must not manage a local database resource %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootInstallerBuildsImageWithoutOperatorImageArgument(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", "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") {
|
||||
t.Fatal("root installer must not require DOBOTAI_IMAGE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerfilePropagatesConfiguredGoProxyToModuleDownload(t *testing.T) {
|
||||
data, err := os.ReadFile("../Dockerfile")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(data)
|
||||
for _, required := range []string{"ARG GOPROXY=", "ENV GOPROXY=${GOPROXY}", "RUN go mod download"} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("Dockerfile missing %q", required)
|
||||
}
|
||||
}
|
||||
if !(strings.Index(source, "ENV GOPROXY=${GOPROXY}") < strings.Index(source, "RUN go mod download")) {
|
||||
t.Fatal("Dockerfile must set GOPROXY before downloading modules")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRDSInitializerOnlyInitializesEmptySchemaAndRejectsInconsistentSchema(t *testing.T) {
|
||||
data, err := os.ReadFile("../deploy/dobotai-dingtalk/rds-schema-init.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(data)
|
||||
for _, required := range []string{"pg_isready", "to_regclass('public.turns')", "empty", "normal", "inconsistent", "数据库不存在", "*.up.sql", "DATABASE_URL", "postgres:16-alpine"} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("RDS initializer missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"docker compose", "dobotai-postgres", "pg_dump -Fc", "DROP DATABASE", "DROP SCHEMA"} {
|
||||
if strings.Contains(source, forbidden) {
|
||||
t.Errorf("RDS initializer must not manage or destroy databases: %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRDSCredentialConfiguratorPromptsForExternalDatabase(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{"DATABASE_URL", "阿里云RDS", "RDS主机地址", "RDS端口", "数据库名", "数据库账号", "数据库密码", "快速问答Agent ID", "智能推理Agent ID", "getpass.getpass", "os.replace", "0o600", "OSS整组录入", "DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET"} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("RDS configurator missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(source, "RDS PostgreSQL连接串") {
|
||||
t.Fatal("operator must not be asked to type a whole DATABASE_URL string")
|
||||
}
|
||||
if strings.Contains(source, "DobotAI租户键") || strings.Contains(source, "Agent Bindings JSON") || strings.Contains(source, "DobotAI Ingress Token") {
|
||||
t.Fatal("operator must only be prompted for real RDS, WeKnora and DingTalk configuration")
|
||||
}
|
||||
installer, err := os.ReadFile("../deploy/dobotai-dingtalk/install-docker.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(installer), "secrets.token_hex") || !strings.Contains(string(installer), "secrets.token_urlsafe") || !strings.Contains(string(installer), "DOBOTAI_TENANT_KEY=dobotai-") {
|
||||
t.Fatal("installer must generate and persist internal tenant and ingress tokens")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user