Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0798a2dac7 | |||
| 18c81232ce | |||
| 19ed4b50df | |||
| 9f65c3eef0 | |||
| 50379b3b87 | |||
| 3f9ff359b9 | |||
| a3defdd1c0 | |||
| b4b68d8b21 | |||
| 365f1b8c7d | |||
| 725db721a8 | |||
| 5d68b22636 | |||
| 6f24743be2 | |||
| 4dbc20ee74 | |||
| e5be9ee780 | |||
| 0f30f8cec8 | |||
| 44d9398c78 | |||
| b207ee4089 | |||
| 0fc64ba77d | |||
| a7432aed49 | |||
| 5036071ac2 | |||
| 2d9490f424 | |||
| 462ee1f3ff | |||
| 8d2d4b469b | |||
| 9363d10e44 | |||
| 74c9af9944 | |||
| 5c35a49486 | |||
| 357e9bad5c | |||
| 2c0ed7cc67 | |||
| aa0513eb59 | |||
| e582d851a4 | |||
| 0a3f62618e | |||
| 5e0694835a | |||
| 39c5fbbaef | |||
| d29b28be13 | |||
| 774ccd9454 | |||
| 86593df57c | |||
| 913a65b16d | |||
| a6569ee4bf | |||
| 5b5865dc07 | |||
| 4366c018f9 | |||
| a952232cc9 | |||
| 4dc3f22784 | |||
| b8f572cc84 | |||
| fcf4559c9a |
@@ -27,5 +27,32 @@ DINGTALK_CARD_TEMPLATE_ID=
|
||||
# Keep empty outside a controlled debug session; never commit a real user ID.
|
||||
DINGTALK_CARD_DEBUG_USER_ID=
|
||||
|
||||
# Optional DingTalk FAQ table workflow for uncovered questions. Set all four
|
||||
# values together; leave all four empty to disable. Required when the support
|
||||
# group notification below is enabled. Never commit real IDs.
|
||||
DINGTALK_FAQ_TABLE_BASE_ID=
|
||||
DINGTALK_FAQ_TABLE_SHEET_ID=
|
||||
DINGTALK_FAQ_TABLE_VIEW_ID=
|
||||
DINGTALK_FAQ_TABLE_OPERATOR_ID=
|
||||
|
||||
# Optional PDF-answer image egress for DingTalk cards. When any value is set,
|
||||
# all five values are required. DobotAI downloads only authenticated resource://
|
||||
# images from WEKNORA_BASE_URL /files, writes content-hashed copies under
|
||||
# dobotai/im-images/, and emits private OSS GET URLs valid for 2 hours.
|
||||
# Keep the bucket private and configure an OSS lifecycle rule to delete
|
||||
# dobotai/im-images/ objects after 1 day. Never commit real credentials.
|
||||
DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT=
|
||||
DOBOTAI_OUTBOUND_IMAGE_OSS_REGION=
|
||||
DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID=
|
||||
DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET=
|
||||
DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET=
|
||||
|
||||
# Optional support group for uncovered-answer notifications. This is the
|
||||
# group's openConversationId, not a webhook. Leave empty to record coverage
|
||||
# decisions and pending support cases without sending group notifications.
|
||||
# When enabled, the FAQ table workflow above must also be configured so the
|
||||
# ActionCard can link to the exact FAQ record. Never commit a real group ID.
|
||||
DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID=
|
||||
|
||||
# Independent DobotAI PostgreSQL; never point this at WeKnora's database
|
||||
DATABASE_URL=postgres://dobotai:replace-me@127.0.0.1:5432/dobotai?sslmode=disable
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
*.local
|
||||
coverage.out
|
||||
|
||||
# Local deployment, backup, and production diagnostic artifacts
|
||||
/.deploy/
|
||||
/backups/
|
||||
/check-live-dobotai.py
|
||||
|
||||
# Editors and OS
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,9 @@ Implemented:
|
||||
- external-message idempotency;
|
||||
- independent PostgreSQL session/turn storage;
|
||||
- authenticated internal `POST /v1/qa` ingress endpoint.
|
||||
- unauthenticated read-only `GET /version` runtime build identity endpoint.
|
||||
- DingTalk Stream text ingress (official SDK) wired into `cmd/dobotai`.
|
||||
- v1 knowledge coverage decisions, durable support cases, and optional DingTalk support-group notifications.
|
||||
|
||||
## DingTalk channel (Stream, text only)
|
||||
|
||||
@@ -25,6 +27,32 @@ Implemented:
|
||||
- Sessions: direct chats use one long-lived session per user; group chats share one session per group (default `shared` policy).
|
||||
- Replies are sent via the callback's `sessionWebhook`, which is fail-closed: only `https` URLs on `*.dingtalk.com` (port 443, no redirects) are accepted.
|
||||
- Required env vars to enable (all three or none): `DINGTALK_CLIENT_ID`, `DINGTALK_CLIENT_SECRET`, `DOBOTAI_TENANT_KEY`. Partial configuration is a fatal startup error.
|
||||
- Optional `DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID` enables durable uncovered-answer ActionCard notifications through the official group-message API. It requires the FAQ table workflow below so every “去回答” button targets an exact record. When empty, coverage decisions and support cases are still recorded but no group message is sent.
|
||||
|
||||
## DingTalk question table sync (Notable v1)
|
||||
|
||||
Successful DingTalk QA turns can be appended asynchronously to a DingTalk Notable AI table through a durable PostgreSQL outbox. Notable failures never change the successful QA result; the worker retries with bounded backoff and stores only stable failure codes.
|
||||
|
||||
Apply `db/migrations/000010_question_table_exports.up.sql` before enabling the worker. The three table settings are optional as a group: all blank disables dispatch, while a partial configuration fails startup closed.
|
||||
|
||||
- `DINGTALK_QUESTION_TABLE_BASE_ID`
|
||||
- `DINGTALK_QUESTION_TABLE_SHEET_ID`
|
||||
- `DINGTALK_QUESTION_TABLE_OPERATOR_ID` (DingTalk unionId)
|
||||
|
||||
The worker reuses `DINGTALK_CLIENT_ID` and `DINGTALK_CLIENT_SECRET`. The DingTalk application must have `Notable.Base.Read.All` and `Notable.Base.Write.All`. Only successful `channel=dingtalk` turns are enqueued; other channels are excluded. The first release writes the nine basic text/date/user fields and omits the five select-classification fields.
|
||||
|
||||
## Uncovered-question FAQ workflow
|
||||
|
||||
Apply `db/migrations/000011_faq_exports.up.sql` before enabling this workflow. Existing support cases are marked `not_applicable` and are not backfilled; only support cases created after the migration default to `pending` FAQ export.
|
||||
|
||||
Set all four values together, or leave all four blank to disable FAQ export. Partial configuration fails startup closed:
|
||||
|
||||
- `DINGTALK_FAQ_TABLE_BASE_ID`
|
||||
- `DINGTALK_FAQ_TABLE_SHEET_ID`
|
||||
- `DINGTALK_FAQ_TABLE_VIEW_ID`
|
||||
- `DINGTALK_FAQ_TABLE_OPERATOR_ID` (DingTalk unionId)
|
||||
|
||||
For every newly detected uncovered answer, the asynchronous worker creates one FAQ record with standard question, original question, detected language, and asker. After DingTalk returns the `recordId`, DobotAI stores it and sends a built-in `sampleActionCard` containing only the current question and up to five earlier questions from the same stable actor. The “去回答” button opens that exact FAQ record. FAQ insert failures are retried with bounded backoff and never block the customer reply.
|
||||
|
||||
## WeKnora baseline
|
||||
|
||||
@@ -47,6 +75,17 @@ Health check:
|
||||
curl http://127.0.0.1:8090/healthz
|
||||
```
|
||||
|
||||
Runtime version check:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8090/version
|
||||
```
|
||||
|
||||
`scripts/deploy-local.sh` builds from its own worktree (or the explicit
|
||||
`DOBOTAI_SOURCE_ROOT`), injects `version`, `commit_sha`, and `build_time`, and
|
||||
accepts the deployment only when `/version` reports the exact source commit.
|
||||
It refuses dirty worktrees so the reported SHA cannot hide uncommitted source.
|
||||
|
||||
Internal QA request:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:], os.Stdout); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string, out io.Writer) error {
|
||||
flags := flag.NewFlagSet("directory-import", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
csvPath := flags.String("csv", "", "path to tested DingTalk directory CSV output")
|
||||
tenant := flags.String("tenant", "", "tenant key")
|
||||
databaseURLFile := flags.String("database-url-file", "", "file containing DATABASE_URL")
|
||||
validateOnly := flags.Bool("validate-only", false, "validate CSV without connecting to PostgreSQL")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return errors.New("invalid directory import arguments")
|
||||
}
|
||||
if strings.TrimSpace(*csvPath) == "" || strings.TrimSpace(*tenant) == "" {
|
||||
return errors.New("-csv and -tenant are required")
|
||||
}
|
||||
input, err := os.Open(*csvPath)
|
||||
if err != nil {
|
||||
return errors.New("open directory CSV failed")
|
||||
}
|
||||
defer input.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if *validateOnly {
|
||||
count, importErr := directory.ImportCSV(ctx, *tenant, input, validationWriter{})
|
||||
if importErr != nil {
|
||||
return errors.New("directory CSV validation failed")
|
||||
}
|
||||
_, _ = fmt.Fprintf(out, "validated_profiles=%d\n", count)
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(*databaseURLFile) == "" {
|
||||
return errors.New("-database-url-file is required for import")
|
||||
}
|
||||
databaseURL, err := readSecretFile(*databaseURLFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store, err := postgres.Open(ctx, databaseURL)
|
||||
if err != nil {
|
||||
return errors.New("open directory database failed")
|
||||
}
|
||||
defer store.Close()
|
||||
count, err := directory.ImportCSV(ctx, *tenant, input, store)
|
||||
if err != nil {
|
||||
return errors.New("directory CSV import failed")
|
||||
}
|
||||
_, _ = fmt.Fprintf(out, "imported_profiles=%d\n", count)
|
||||
return nil
|
||||
}
|
||||
|
||||
type validationWriter struct{}
|
||||
|
||||
func (validationWriter) UpsertDirectoryProfiles(context.Context, string, []directory.Profile) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func readSecretFile(path string) (string, error) {
|
||||
raw, err := os.ReadFile(strings.TrimSpace(path))
|
||||
if err != nil { return "", errors.New("read database URL file failed") }
|
||||
if len(raw) > 16<<10 { return "", errors.New("database URL file is too large") }
|
||||
value := strings.TrimSpace(string(raw))
|
||||
if value == "" { return "", errors.New("database URL file is empty") }
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadSecretFileTrimsValue(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "database_url")
|
||||
if err := os.WriteFile(path, []byte(" postgres://secret-value \n"), 0o600); err != nil { t.Fatal(err) }
|
||||
value, err := readSecretFile(path)
|
||||
if err != nil { t.Fatalf("readSecretFile: %v", err) }
|
||||
if value != "postgres://secret-value" { t.Fatalf("value=%q", value) }
|
||||
}
|
||||
|
||||
func TestReadSecretFileErrorDoesNotLeakContents(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "empty")
|
||||
if err := os.WriteFile(path, []byte(""), 0o600); err != nil { t.Fatal(err) }
|
||||
_, err := readSecretFile(path)
|
||||
if err == nil { t.Fatal("expected error") }
|
||||
if strings.Contains(err.Error(), "secret-value") { t.Fatalf("error leaked secret: %v", err) }
|
||||
}
|
||||
|
||||
func TestRunValidateOnlyDoesNotRequireDatabase(t *testing.T) {
|
||||
csvPath := filepath.Join(t.TempDir(), "directory.csv")
|
||||
if err := os.WriteFile(csvPath, []byte("user_id,union_id,name,nickname,title,department_ids,dept_name\nstaff-1,,张三,,工程师,100,技术支持部\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
err := run([]string{"-csv", csvPath, "-tenant", "tenant-a", "-validate-only"}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("run validate-only: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "validated_profiles=1") {
|
||||
t.Fatalf("output=%q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRequiresCSVAndDatabaseURLFileBeforeConnecting(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
for _, args := range [][]string{{}, {"-csv", "snapshot.csv"}} {
|
||||
err := run(args, &out)
|
||||
if err == nil { t.Fatalf("run(%v) expected error", args) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMainWiresDirectoryResolverIntoDingTalkHandler(t *testing.T) {
|
||||
raw, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read main.go: %v", err)
|
||||
}
|
||||
src := string(raw)
|
||||
for _, required := range []string{
|
||||
"directory.NewDingTalkLookup",
|
||||
"directory.NewResolver",
|
||||
} {
|
||||
if !strings.Contains(src, required) {
|
||||
t.Errorf("main.go missing %q", required)
|
||||
}
|
||||
}
|
||||
if !regexp.MustCompile(`Directory:\s*directoryResolver`).MatchString(src) {
|
||||
t.Error("main.go does not wire directoryResolver into HandlerOptions.Directory")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
|
||||
)
|
||||
|
||||
type faqConfig struct {
|
||||
BaseID string
|
||||
SheetID string
|
||||
ViewID string
|
||||
OperatorID string
|
||||
FieldMapping map[string]string
|
||||
}
|
||||
|
||||
func readFAQConfig() (faqConfig, error) {
|
||||
cfg := faqConfig{
|
||||
BaseID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_BASE_ID")),
|
||||
SheetID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_SHEET_ID")),
|
||||
ViewID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_VIEW_ID")),
|
||||
OperatorID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_OPERATOR_ID")),
|
||||
}
|
||||
blank := cfg.BaseID == "" && cfg.SheetID == "" && cfg.ViewID == "" && cfg.OperatorID == ""
|
||||
complete := cfg.BaseID != "" && cfg.SheetID != "" && cfg.ViewID != "" && cfg.OperatorID != ""
|
||||
if blank {
|
||||
return faqConfig{}, nil
|
||||
}
|
||||
if !complete {
|
||||
return faqConfig{}, errors.New("DingTalk FAQ table config must be complete or disabled")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func buildFAQDispatcher(repo faqtable.ExportRepository, clientID, clientSecret string, cfg faqConfig) (*faqtable.Dispatcher, error) {
|
||||
if cfg.BaseID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if repo == nil {
|
||||
return nil, errors.New("FAQ dispatcher repository is required")
|
||||
}
|
||||
client, err := questiontable.NewClient(questiontable.Config{
|
||||
ClientID: clientID, Secret: clientSecret, BaseID: cfg.BaseID,
|
||||
SheetID: cfg.SheetID, OperatorID: cfg.OperatorID, FieldMapping: cfg.FieldMapping,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return faqtable.NewDispatcher(repo, client, nil)
|
||||
}
|
||||
|
||||
type faqPollerRunner interface {
|
||||
RunOnce(context.Context) (faqtable.DispatcherStats, error)
|
||||
}
|
||||
|
||||
func runFAQPoller(ctx context.Context, runner faqPollerRunner, interval time.Duration) {
|
||||
if runner == nil || interval <= 0 {
|
||||
return
|
||||
}
|
||||
run := func() {
|
||||
runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
stats, err := runner.RunOnce(runCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("FAQ table dispatch failed")
|
||||
return
|
||||
}
|
||||
if stats.Claimed > 0 {
|
||||
log.Printf("FAQ table dispatch: claimed=%d exported=%d failed=%d", stats.Claimed, stats.Exported, stats.Failed)
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
run()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
run()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
|
||||
)
|
||||
|
||||
type faqRepoStub struct{}
|
||||
|
||||
func (*faqRepoStub) Claim(context.Context, int, int, time.Time) ([]faqtable.PendingExport, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*faqRepoStub) MarkExported(context.Context, string, string) error { return nil }
|
||||
func (*faqRepoStub) MarkFailed(context.Context, string, string) error { return nil }
|
||||
|
||||
func TestReadFAQConfigDisabledWhenAllBlank(t *testing.T) {
|
||||
for _, key := range []string{"DINGTALK_FAQ_TABLE_BASE_ID", "DINGTALK_FAQ_TABLE_SHEET_ID", "DINGTALK_FAQ_TABLE_VIEW_ID", "DINGTALK_FAQ_TABLE_OPERATOR_ID"} {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
cfg, err := readFAQConfig()
|
||||
if err != nil || cfg.BaseID != "" {
|
||||
t.Fatalf("cfg=%+v err=%v", cfg, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFAQConfigRejectsPartialValues(t *testing.T) {
|
||||
t.Setenv("DINGTALK_FAQ_TABLE_BASE_ID", "base")
|
||||
t.Setenv("DINGTALK_FAQ_TABLE_SHEET_ID", "")
|
||||
t.Setenv("DINGTALK_FAQ_TABLE_VIEW_ID", "view")
|
||||
t.Setenv("DINGTALK_FAQ_TABLE_OPERATOR_ID", "operator")
|
||||
if _, err := readFAQConfig(); err == nil {
|
||||
t.Fatal("expected partial-config error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFAQDispatcherRequiresRepositoryWhenEnabled(t *testing.T) {
|
||||
cfg := faqConfig{BaseID: "base", SheetID: "sheet", ViewID: "view", OperatorID: "operator"}
|
||||
if _, err := buildFAQDispatcher(nil, "client", "secret", cfg); err == nil {
|
||||
t.Fatal("expected repository error")
|
||||
}
|
||||
if d, err := buildFAQDispatcher(&faqRepoStub{}, "client", "secret", cfg); err != nil || d == nil {
|
||||
t.Fatalf("dispatcher=%v err=%v", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainWiresFAQExportBeforeSupportNotification(t *testing.T) {
|
||||
data, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(data)
|
||||
for _, fragment := range []string{
|
||||
"readFAQConfig()", "postgres.NewFAQExportRepository(store)",
|
||||
"buildFAQDispatcher(", "runFAQPoller(ctx, faqDispatcher, 5*time.Second)",
|
||||
"faqtable.NewRecordLinkBuilder(", "buildSupportDispatcher(",
|
||||
} {
|
||||
if !strings.Contains(source, fragment) {
|
||||
t.Errorf("main FAQ wiring missing %q", fragment)
|
||||
}
|
||||
}
|
||||
if strings.Index(source, "runFAQPoller(ctx") > strings.Index(source, "runSupportDispatcher(ctx") {
|
||||
t.Fatal("FAQ exporter must start before support notifications")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvExampleDocumentsFAQWorkflowConfig(t *testing.T) {
|
||||
data, err := os.ReadFile("../../.env.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"DINGTALK_FAQ_TABLE_BASE_ID", "DINGTALK_FAQ_TABLE_SHEET_ID", "DINGTALK_FAQ_TABLE_VIEW_ID", "DINGTALK_FAQ_TABLE_OPERATOR_ID"} {
|
||||
if !strings.Contains(string(data), key+"=") {
|
||||
t.Errorf(".env.example missing %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
+261
-8
@@ -8,12 +8,17 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"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"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/httpapi"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/qa"
|
||||
@@ -22,9 +27,24 @@ import (
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commitSHA = "unknown"
|
||||
buildTime = "unknown"
|
||||
)
|
||||
|
||||
func runtimeBuildInfo() httpapi.BuildInfo {
|
||||
return httpapi.BuildInfo{
|
||||
Version: version,
|
||||
CommitSHA: commitSHA,
|
||||
BuildTime: buildTime,
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
var restartRequested atomic.Bool
|
||||
storeCtx, storeCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer storeCancel()
|
||||
store, err := postgres.Open(storeCtx, os.Getenv("DATABASE_URL"))
|
||||
@@ -32,6 +52,20 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
settingsRuntime, err := buildSettingsRuntime(storeCtx, store, os.Getenv("DOBOTAI_TENANT_KEY"))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
settingsRuntime.bindings.restartDelay = 300 * time.Millisecond
|
||||
settingsRuntime.bindings.restart = func() { restartRequested.Store(true); cancel() }
|
||||
address := strings.TrimSpace(os.Getenv("DOBOTAI_LISTEN_ADDR"))
|
||||
if address == "" {
|
||||
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
|
||||
}
|
||||
client, err := weknora.NewClient(os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"), 120*time.Second)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -47,33 +81,175 @@ func main() {
|
||||
if groupPolicy != session.GroupShared && groupPolicy != session.GroupPerUser {
|
||||
log.Fatal("DOBOTAI_GROUP_SESSION_POLICY must be shared or per_user")
|
||||
}
|
||||
qaService, err := qa.New(store, bindings, client, groupPolicy)
|
||||
coverageService, err := coverage.NewService(store)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
address := strings.TrimSpace(os.Getenv("DOBOTAI_LISTEN_ADDR"))
|
||||
if address == "" {
|
||||
address = "127.0.0.1:8090"
|
||||
qaService, err := qa.New(
|
||||
store,
|
||||
bindings,
|
||||
client,
|
||||
groupPolicy,
|
||||
qa.WithSucceededTurnObserver(coverageService, func(err error) {
|
||||
log.Printf("coverage persistence failed: %v", err)
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
auditOptions, err := buildAuditOptions(store, os.Getenv("DOBOTAI_TENANT_KEY"), os.Getenv("DOBOTAI_ADMIN_TOKEN"), address)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
auditOptions.BuildInfo = runtimeBuildInfo()
|
||||
api, err := httpapi.NewWithOptions(qaService, os.Getenv("DOBOTAI_INGRESS_TOKEN"), auditOptions)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
dingCfg := dingtalk.StreamConfig{
|
||||
ClientID: os.Getenv("DINGTALK_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("DINGTALK_CLIENT_SECRET"),
|
||||
ClientID: settingsRuntime.clientID,
|
||||
ClientSecret: settingsRuntime.secret,
|
||||
TenantKey: os.Getenv("DOBOTAI_TENANT_KEY"),
|
||||
}
|
||||
if err := dingCfg.Validate(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
activeBindings, err := settingsRuntime.repository.ActiveBindings(storeCtx, dingCfg.TenantKey)
|
||||
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 {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if binding, ok := activeBindings["faq_table"]; ok {
|
||||
faqCfg = faqConfig{BaseID: binding.BaseID, SheetID: binding.SheetID, ViewID: binding.ViewID, OperatorID: binding.OperatorID, FieldMapping: binding.FieldMapping}
|
||||
}
|
||||
var faqWorkflowDone chan struct{}
|
||||
var faqRecordLinks *faqtable.RecordLinkBuilder
|
||||
if faqCfg.BaseID != "" {
|
||||
faqRecordLinks, err = faqtable.NewRecordLinkBuilder(faqCfg.BaseID, faqCfg.SheetID, faqCfg.ViewID)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
faqRepo, err := postgres.NewFAQExportRepository(store)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
faqDispatcher, err := buildFAQDispatcher(faqRepo, dingCfg.ClientID, dingCfg.ClientSecret, faqCfg)
|
||||
if err != nil {
|
||||
log.Fatalf("DingTalk FAQ table dispatcher init failed: %v", err)
|
||||
}
|
||||
faqWorkflowDone = make(chan struct{})
|
||||
go func() {
|
||||
defer close(faqWorkflowDone)
|
||||
select {
|
||||
case <-runtimeReady:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
runFAQPoller(ctx, faqDispatcher, 5*time.Second)
|
||||
}()
|
||||
log.Printf("DingTalk FAQ table dispatcher started")
|
||||
}
|
||||
|
||||
supportConversationID := os.Getenv("DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID")
|
||||
if binding, ok := activeBindings["support_group"]; ok {
|
||||
supportConversationID = binding.ConversationID
|
||||
}
|
||||
supportDispatcher, err := buildSupportDispatcher(
|
||||
store,
|
||||
dingCfg.ClientID,
|
||||
dingCfg.ClientSecret,
|
||||
supportConversationID,
|
||||
faqRecordLinks,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("DingTalk support notification init failed: %v", err)
|
||||
}
|
||||
if supportDispatcher != nil {
|
||||
go func() {
|
||||
select {
|
||||
case <-runtimeReady:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
runSupportDispatcher(ctx, supportDispatcher, 5*time.Second)
|
||||
}()
|
||||
log.Printf("DingTalk support notification dispatcher started")
|
||||
}
|
||||
|
||||
// Outbound image runtime (stable-image publishing) is built from environment
|
||||
// variables independently of the Stream ingress, so both the question-table
|
||||
// answer rewriter and the inbound-image publisher can share it.
|
||||
outboundImages, err := buildOutboundImageRuntime(outboundImageConfig{
|
||||
WeKnoraBaseURL: os.Getenv("WEKNORA_BASE_URL"),
|
||||
WeKnoraAPIKey: os.Getenv("WEKNORA_API_KEY"),
|
||||
Endpoint: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT"),
|
||||
Region: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_REGION"),
|
||||
AccessKeyID: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID"),
|
||||
AccessKeySecret: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET"),
|
||||
Bucket: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET"),
|
||||
PublicBaseURL: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("DingTalk outbound image init failed: %v", err)
|
||||
}
|
||||
|
||||
// Question table dispatcher (optional, fail-closed).
|
||||
qtCfg, err := readQuestionTableConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if binding, ok := activeBindings["question_table"]; ok {
|
||||
qtCfg = questionTableConfig{BaseID: binding.BaseID, SheetID: binding.SheetID, OperatorID: binding.OperatorID, FieldMapping: binding.FieldMapping}
|
||||
}
|
||||
store.SetExternalTableExports(qtCfg.BaseID != "", faqCfg.BaseID != "")
|
||||
var questionTableDone chan struct{}
|
||||
if qtCfg.BaseID != "" {
|
||||
qtRepo, err := postgres.NewQuestionTableExportRepository(store)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
qtDispatcher, err := buildQuestionTableDispatcher(qtRepo, dingCfg.ClientID, dingCfg.ClientSecret, qtCfg, answerRewriterAdapter{rewriter: outboundImages.rewriter})
|
||||
if err != nil {
|
||||
log.Fatalf("DingTalk question table dispatcher init failed: %v", err)
|
||||
}
|
||||
questionTableDone = make(chan struct{})
|
||||
go func() {
|
||||
defer close(questionTableDone)
|
||||
select {
|
||||
case <-runtimeReady:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
runQuestionTablePoller(ctx, qtDispatcher, 5*time.Second)
|
||||
}()
|
||||
log.Printf("DingTalk question table dispatcher started")
|
||||
}
|
||||
var streamClient *dingtalk.StreamClient
|
||||
if dingCfg.Enabled() {
|
||||
directoryLookup, err := directory.NewDingTalkLookup(directory.DingTalkLookupConfig{
|
||||
TokenURL: "https://oapi.dingtalk.com/gettoken",
|
||||
APIBaseURL: "https://oapi.dingtalk.com",
|
||||
ClientID: dingCfg.ClientID,
|
||||
ClientSecret: dingCfg.ClientSecret,
|
||||
Timeout: 10 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("DingTalk directory lookup init failed: %v", err)
|
||||
}
|
||||
directoryResolver, err := directory.NewResolver(store, directoryLookup)
|
||||
if err != nil {
|
||||
log.Fatalf("DingTalk directory resolver init failed: %v", err)
|
||||
}
|
||||
feedbackService, err := feedback.NewService(dingCfg.TenantKey, "dingtalk", store)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -112,7 +288,15 @@ func main() {
|
||||
dingtalk.TenantConfig{TenantKey: dingCfg.TenantKey, Channel: "dingtalk"},
|
||||
qaService,
|
||||
sender,
|
||||
dingtalk.HandlerOptions{ImageDownloader: downloader, CardSender: cardSender},
|
||||
dingtalk.HandlerOptions{
|
||||
ImageDownloader: downloader,
|
||||
ImagePublisher: outboundImages.publisher,
|
||||
CardSender: cardSender,
|
||||
Directory: directoryResolver,
|
||||
OutboundRewriter: outboundImages.rewriter,
|
||||
SettingsRouter: &dingTalkSettingsRouter{controller: settingsRuntime.controller},
|
||||
LanguageResolver: settingsRuntime.repository,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -125,9 +309,45 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer streamClient.Close()
|
||||
if err := settingsRuntime.repository.MarkDingTalkRuntimeHealthy(context.Background(), dingCfg.TenantKey, settingsRuntime.secretRef); err != nil {
|
||||
streamClient.Close()
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("DingTalk Stream ingress started for tenant %s", dingCfg.TenantKey)
|
||||
}
|
||||
server := &http.Server{Addr: address, Handler: api.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 130 * time.Second, IdleTimeout: 60 * time.Second}
|
||||
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)
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
@@ -141,6 +361,39 @@ func main() {
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("HTTP shutdown: %v", err)
|
||||
}
|
||||
if faqWorkflowDone != nil {
|
||||
select {
|
||||
case <-faqWorkflowDone:
|
||||
case <-shutdownCtx.Done():
|
||||
log.Printf("FAQ table poller shutdown timed out")
|
||||
}
|
||||
}
|
||||
if questionTableDone != nil {
|
||||
select {
|
||||
case <-questionTableDone:
|
||||
case <-shutdownCtx.Done():
|
||||
log.Printf("question table poller shutdown timed out")
|
||||
}
|
||||
}
|
||||
if streamClient != nil {
|
||||
streamClient.Close()
|
||||
}
|
||||
if restartRequested.Load() {
|
||||
os.Exit(75)
|
||||
}
|
||||
}
|
||||
|
||||
func outboundOSSConfigured() bool {
|
||||
for _, key := range []string{
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT", "DOBOTAI_OUTBOUND_IMAGE_OSS_REGION",
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID", "DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET",
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET", "DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL",
|
||||
} {
|
||||
if strings.TrimSpace(os.Getenv(key)) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func buildAuditOptions(repo audit.TurnRepository, tenantKey, adminToken, address string) (httpapi.Options, error) {
|
||||
|
||||
@@ -21,6 +21,20 @@ func (r *auditRepoStub) GetTurn(context.Context, string, string) (audit.TurnDeta
|
||||
return audit.TurnDetail{}, audit.ErrNotFound
|
||||
}
|
||||
|
||||
func TestRuntimeBuildInfoUsesLinkerVariables(t *testing.T) {
|
||||
oldVersion, oldCommitSHA, oldBuildTime := version, commitSHA, buildTime
|
||||
t.Cleanup(func() {
|
||||
version, commitSHA, buildTime = oldVersion, oldCommitSHA, oldBuildTime
|
||||
})
|
||||
version = "2.1.0"
|
||||
commitSHA = "abc123def456"
|
||||
buildTime = "2026-07-27T12:00:00Z"
|
||||
got := runtimeBuildInfo()
|
||||
if got.Version != version || got.CommitSHA != commitSHA || got.BuildTime != buildTime {
|
||||
t.Fatalf("build info=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuditOptionsDisabledIsBackwardCompatible(t *testing.T) {
|
||||
opts, err := buildAuditOptions(nil, "", "", "0.0.0.0:8090")
|
||||
if err != nil {
|
||||
@@ -101,3 +115,71 @@ func TestMainWiresOptionalDingTalkCardDebugUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutboundImageRuntimeConfig(t *testing.T) {
|
||||
t.Run("disabled", func(t *testing.T) {
|
||||
runtime, err := buildOutboundImageRuntime(outboundImageConfig{})
|
||||
if err != nil || runtime.rewriter != nil || runtime.handler != nil {
|
||||
t.Fatalf("runtime=%+v err=%v", runtime, err)
|
||||
}
|
||||
})
|
||||
t.Run("partial configuration rejected", func(t *testing.T) {
|
||||
_, err := buildOutboundImageRuntime(outboundImageConfig{Bucket: "private-bucket"})
|
||||
if err == nil {
|
||||
t.Fatal("expected configuration error")
|
||||
}
|
||||
})
|
||||
t.Run("complete configuration", func(t *testing.T) {
|
||||
runtime, err := buildOutboundImageRuntime(outboundImageConfig{
|
||||
WeKnoraBaseURL: "http://127.0.0.1:8080",
|
||||
WeKnoraAPIKey: "api-key",
|
||||
Endpoint: "https://oss-cn-shenzhen.aliyuncs.com",
|
||||
Region: "cn-shenzhen",
|
||||
AccessKeyID: "access-key-id",
|
||||
AccessKeySecret: "access-key-secret",
|
||||
Bucket: "private-bucket",
|
||||
PublicBaseURL: "https://weknora-api-test.dobot.cn/dingtalk/i",
|
||||
})
|
||||
if err != nil || runtime.rewriter == nil || runtime.handler == nil {
|
||||
t.Fatalf("runtime=%+v err=%v", runtime, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMainWiresStableOutboundImageRuntime(t *testing.T) {
|
||||
data, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(data)
|
||||
for _, fragment := range []string{
|
||||
`buildOutboundImageRuntime(outboundImageConfig{`,
|
||||
`PublicBaseURL: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL")`,
|
||||
`OutboundRewriter: outboundImages.rewriter`,
|
||||
`rootHandler.Handle(outboundImageRoute, outboundImages.handler)`,
|
||||
} {
|
||||
if !strings.Contains(source, fragment) {
|
||||
t.Errorf("main stable image wiring missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundOSSConfiguredRequiresStablePublicBaseURL(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT",
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_REGION",
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID",
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET",
|
||||
"DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET",
|
||||
} {
|
||||
t.Setenv(key, "configured")
|
||||
}
|
||||
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL", "")
|
||||
if outboundOSSConfigured() {
|
||||
t.Fatal("stable public base URL must be required")
|
||||
}
|
||||
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL", "https://img.example/dingtalk/i")
|
||||
if !outboundOSSConfigured() {
|
||||
t.Fatal("complete stable image configuration should be active")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/outboundimage"
|
||||
)
|
||||
|
||||
const (
|
||||
outboundImageMaxCount = 5
|
||||
outboundImageMaxBytes = 10 << 20
|
||||
outboundImagePrefix = "dobotai/im-images"
|
||||
outboundImageRoute = "/dingtalk/i/"
|
||||
)
|
||||
|
||||
type outboundImageConfig struct {
|
||||
WeKnoraBaseURL string
|
||||
WeKnoraAPIKey string
|
||||
Endpoint string
|
||||
Region string
|
||||
AccessKeyID string
|
||||
AccessKeySecret string
|
||||
Bucket string
|
||||
PublicBaseURL string
|
||||
}
|
||||
|
||||
type outboundImageRuntime struct {
|
||||
rewriter dingtalk.OutboundContentRewriter
|
||||
publisher *outboundimage.StableHashPublisher
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
func buildOutboundImageRuntime(cfg outboundImageConfig) (outboundImageRuntime, error) {
|
||||
ossValues := []string{cfg.Endpoint, cfg.Region, cfg.AccessKeyID, cfg.AccessKeySecret, cfg.Bucket, cfg.PublicBaseURL}
|
||||
enabled := false
|
||||
for _, value := range ossValues {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
enabled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !enabled {
|
||||
return outboundImageRuntime{}, nil
|
||||
}
|
||||
for _, value := range append([]string{cfg.WeKnoraBaseURL, cfg.WeKnoraAPIKey}, ossValues...) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return outboundImageRuntime{}, errors.New("outbound image: complete WeKnora, OSS, and public URL configuration is required")
|
||||
}
|
||||
}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
fetcher, err := outboundimage.NewWeKnoraFetcher(
|
||||
cfg.WeKnoraBaseURL,
|
||||
cfg.WeKnoraAPIKey,
|
||||
&http.Client{Timeout: 30 * time.Second, Transport: transport},
|
||||
outboundImageMaxBytes,
|
||||
)
|
||||
if err != nil {
|
||||
return outboundImageRuntime{}, err
|
||||
}
|
||||
store, err := outboundimage.NewAliyunOSSStore(
|
||||
cfg.Endpoint,
|
||||
cfg.Region,
|
||||
cfg.AccessKeyID,
|
||||
cfg.AccessKeySecret,
|
||||
cfg.Bucket,
|
||||
)
|
||||
if err != nil {
|
||||
return outboundImageRuntime{}, err
|
||||
}
|
||||
publisher, err := outboundimage.NewStableHashPublisher(store, outboundImagePrefix, cfg.PublicBaseURL)
|
||||
if err != nil {
|
||||
return outboundImageRuntime{}, err
|
||||
}
|
||||
handler, err := outboundimage.NewStableImageHandler(store, outboundImagePrefix, outboundImageRoute, outboundImageMaxBytes)
|
||||
if err != nil {
|
||||
return outboundImageRuntime{}, err
|
||||
}
|
||||
return outboundImageRuntime{
|
||||
rewriter: outboundimage.NewRewriter(fetcher, publisher, outboundImageMaxCount),
|
||||
publisher: publisher,
|
||||
handler: handler,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundContentRewriter, error) {
|
||||
runtime, err := buildOutboundImageRuntime(cfg)
|
||||
return runtime.rewriter, err
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
|
||||
)
|
||||
|
||||
// questionTablePollerRunner is the interface expected by the poller loop.
|
||||
type questionTablePollerRunner interface {
|
||||
RunOnce(context.Context) (questiontable.DispatcherStats, error)
|
||||
}
|
||||
|
||||
// questionTableConfig holds the optional Notable question-table env values.
|
||||
type questionTableConfig struct {
|
||||
BaseID string
|
||||
SheetID string
|
||||
OperatorID string
|
||||
FieldMapping map[string]string
|
||||
}
|
||||
|
||||
// readQuestionTableConfig reads the three dedicated env vars and returns
|
||||
// a validated config. All three blank => disabled (no error). Any partial
|
||||
// configuration => stable configuration error (no values echoed).
|
||||
func readQuestionTableConfig() (questionTableConfig, error) {
|
||||
cfg := questionTableConfig{
|
||||
BaseID: strings.TrimSpace(os.Getenv("DINGTALK_QUESTION_TABLE_BASE_ID")),
|
||||
SheetID: strings.TrimSpace(os.Getenv("DINGTALK_QUESTION_TABLE_SHEET_ID")),
|
||||
OperatorID: strings.TrimSpace(os.Getenv("DINGTALK_QUESTION_TABLE_OPERATOR_ID")),
|
||||
}
|
||||
|
||||
allBlank := cfg.BaseID == "" && cfg.SheetID == "" && cfg.OperatorID == ""
|
||||
allSet := cfg.BaseID != "" && cfg.SheetID != "" && cfg.OperatorID != ""
|
||||
|
||||
if allBlank {
|
||||
return questionTableConfig{}, nil
|
||||
}
|
||||
if allSet {
|
||||
return cfg, nil
|
||||
}
|
||||
return questionTableConfig{}, errors.New("DingTalk question table config: all three env vars must be set or all blank (DINGTALK_QUESTION_TABLE_BASE_ID, DINGTALK_QUESTION_TABLE_SHEET_ID, DINGTALK_QUESTION_TABLE_OPERATOR_ID)")
|
||||
}
|
||||
|
||||
// buildQuestionTableDispatcher creates a validated question-table dispatcher
|
||||
// when the repo is non-nil and all required credentials are present.
|
||||
// The optional answer rewriter converts the stored answer into the same
|
||||
// outbound form used for DingTalk delivery (resource images to stable HTTPS,
|
||||
// document links, anchor cleanup). No HTTP request occurs at build time.
|
||||
// It returns (nil, nil) when the question-table feature is disabled.
|
||||
func buildQuestionTableDispatcher(repo questiontable.ExportRepository, clientID, clientSecret string, cfg questionTableConfig, rewriter questiontable.AnswerRewriter) (*questiontable.Dispatcher, error) {
|
||||
// Disabled: all blank config.
|
||||
if cfg.BaseID == "" && cfg.SheetID == "" && cfg.OperatorID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if repo == nil {
|
||||
return nil, errors.New("question table dispatcher: repository is required")
|
||||
}
|
||||
|
||||
client, err := questiontable.NewClient(questiontable.Config{
|
||||
ClientID: clientID,
|
||||
Secret: clientSecret,
|
||||
BaseID: cfg.BaseID,
|
||||
SheetID: cfg.SheetID,
|
||||
OperatorID: cfg.OperatorID,
|
||||
FieldMapping: cfg.FieldMapping,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return questiontable.NewDispatcher(repo, client, &questiontable.DispatcherOptions{
|
||||
Rewriter: rewriter,
|
||||
SendTimeout: 60 * time.Second,
|
||||
})
|
||||
}
|
||||
|
||||
// answerRewriterAdapter adapts a blocking outbound content rewriter to the
|
||||
// AnswerRewriter contract (caller never gets an error, so the original answer
|
||||
// is always written when conversion cannot run).
|
||||
type answerRewriterAdapter struct {
|
||||
rewriter dingtalk.OutboundContentRewriter
|
||||
}
|
||||
|
||||
func (a answerRewriterAdapter) Rewrite(ctx context.Context, content string) (string, error) {
|
||||
if a.rewriter == nil {
|
||||
return content, nil
|
||||
}
|
||||
return a.rewriter.Rewrite(ctx, content), nil
|
||||
}
|
||||
|
||||
// runQuestionTablePoller runs the dispatcher immediately, then every interval.
|
||||
// Non-positive interval or nil/typed-nil runner returns without calling.
|
||||
// Continues after a RunOnce error. Exits promptly on context cancellation.
|
||||
// Logging is safe: success may log only Claimed/Exported/Failed integers;
|
||||
// errors log stable stage text only, never raw errors.
|
||||
func runQuestionTablePoller(ctx context.Context, runner questionTablePollerRunner, interval time.Duration) {
|
||||
if runner == nil || interval <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Guard against typed nil.
|
||||
if isNilPoller(runner) {
|
||||
return
|
||||
}
|
||||
|
||||
runOnce := func() {
|
||||
runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
stats, err := runner.RunOnce(runCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("question table dispatch failed")
|
||||
return
|
||||
}
|
||||
if stats.Claimed > 0 || stats.Exported > 0 || stats.Failed > 0 {
|
||||
log.Printf("question table dispatch: claimed=%d exported=%d failed=%d", stats.Claimed, stats.Exported, stats.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
runOnce()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isNilPoller returns true when runner is a typed nil.
|
||||
func isNilPoller(runner questionTablePollerRunner) bool {
|
||||
if runner == nil {
|
||||
return true
|
||||
}
|
||||
// reflect-based typed-nil check for the interface.
|
||||
return isInterfaceNil(runner)
|
||||
}
|
||||
|
||||
// isInterfaceNil returns true when v is a typed nil wrapped in an interface.
|
||||
func isInterfaceNil(v interface{}) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
|
||||
return rv.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1: disabled all blank returns nil/no error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestReadQuestionTableConfigDisabledAllBlank(t *testing.T) {
|
||||
t.Setenv("DINGTALK_QUESTION_TABLE_BASE_ID", "")
|
||||
t.Setenv("DINGTALK_QUESTION_TABLE_SHEET_ID", "")
|
||||
t.Setenv("DINGTALK_QUESTION_TABLE_OPERATOR_ID", "")
|
||||
|
||||
cfg, err := readQuestionTableConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.BaseID != "" || cfg.SheetID != "" || cfg.OperatorID != "" {
|
||||
t.Errorf("expected empty config, got %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuestionTableDispatcherDisabled(t *testing.T) {
|
||||
d, err := buildQuestionTableDispatcher(nil, "", "", questionTableConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d != nil {
|
||||
t.Errorf("expected nil dispatcher for disabled config, got %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2: every partial combination errors and does not echo sentinel config values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestReadQuestionTableConfigPartialErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseID string
|
||||
sheetID string
|
||||
operator string
|
||||
}{
|
||||
{"only base", "base-1", "", ""},
|
||||
{"only sheet", "", "sheet-1", ""},
|
||||
{"only operator", "", "", "op-1"},
|
||||
{"base+sheet", "base-1", "sheet-1", ""},
|
||||
{"base+operator", "base-1", "", "op-1"},
|
||||
{"sheet+operator", "", "sheet-1", "op-1"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("DINGTALK_QUESTION_TABLE_BASE_ID", tc.baseID)
|
||||
t.Setenv("DINGTALK_QUESTION_TABLE_SHEET_ID", tc.sheetID)
|
||||
t.Setenv("DINGTALK_QUESTION_TABLE_OPERATOR_ID", tc.operator)
|
||||
|
||||
_, err := readQuestionTableConfig()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for partial config")
|
||||
}
|
||||
errStr := err.Error()
|
||||
// Must not echo any sentinel config value.
|
||||
for _, sentinel := range []string{"base-1", "sheet-1", "op-1"} {
|
||||
if strings.Contains(errStr, sentinel) {
|
||||
t.Errorf("error must not echo sentinel value %q: %q", sentinel, errStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 3: enabled missing repo/client/secret fails closed; complete config builds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBuildQuestionTableDispatcherEnabledMissingRepo(t *testing.T) {
|
||||
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
|
||||
_, err := buildQuestionTableDispatcher(nil, "cid", "secret", cfg, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when repo is nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "repository is required") {
|
||||
t.Errorf("error = %v, want 'repository is required'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuestionTableDispatcherEnabledMissingClientID(t *testing.T) {
|
||||
repo := &questionTableExportRepoStub{}
|
||||
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
|
||||
_, err := buildQuestionTableDispatcher(repo, "", "secret", cfg, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when client id is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuestionTableDispatcherEnabledMissingSecret(t *testing.T) {
|
||||
repo := &questionTableExportRepoStub{}
|
||||
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
|
||||
_, err := buildQuestionTableDispatcher(repo, "cid", "", cfg, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when secret is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuestionTableDispatcherEnabledCompleteConfig(t *testing.T) {
|
||||
repo := &questionTableExportRepoStub{}
|
||||
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
|
||||
d, err := buildQuestionTableDispatcher(repo, "cid", "secret", cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d == nil {
|
||||
t.Fatal("expected non-nil dispatcher for complete config")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 4: immediate run + periodic repeat + cancel stop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRunQuestionTablePollerImmediateAndPeriodic(t *testing.T) {
|
||||
runner := &countingPollerRunner{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
|
||||
}()
|
||||
|
||||
// Wait for at least 2 calls (immediate + one tick).
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
cancel()
|
||||
wg.Wait()
|
||||
|
||||
runner.mu.Lock()
|
||||
calls := runner.calls
|
||||
runner.mu.Unlock()
|
||||
|
||||
if calls < 2 {
|
||||
t.Errorf("expected at least 2 calls, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQuestionTablePollerCancelledContext(t *testing.T) {
|
||||
runner := &countingPollerRunner{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // already cancelled
|
||||
|
||||
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
|
||||
|
||||
runner.mu.Lock()
|
||||
calls := runner.calls
|
||||
runner.mu.Unlock()
|
||||
|
||||
if calls != 0 {
|
||||
t.Errorf("expected 0 calls (cancelled context), got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 5: continue after error and log/output security contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRunQuestionTablePollerContinuesAfterError(t *testing.T) {
|
||||
runner := &errorThenSuccessPoller{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
runQuestionTablePoller(ctx, runner, 30*time.Millisecond)
|
||||
}()
|
||||
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
cancel()
|
||||
wg.Wait()
|
||||
|
||||
runner.mu.Lock()
|
||||
calls := runner.calls
|
||||
runner.mu.Unlock()
|
||||
|
||||
if calls < 2 {
|
||||
t.Errorf("expected at least 2 calls after error, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// Test logging contract: the runOnce function in runQuestionTablePoller logs
|
||||
// only stable stage text for errors (never raw errors via %v) and only
|
||||
// Claimed/Exported/Failed integers for success.
|
||||
func TestPollerLoggingContract(t *testing.T) {
|
||||
// Behavioral log-capture test: runner returns an error containing unique
|
||||
// sentinel values; the captured log must contain the stable stage text
|
||||
// and none of those sentinels.
|
||||
sentinel := "s3nt1n3l-op3r4t0r-t0k3n-q"
|
||||
runner := &sentinelErrorPoller{errMsg: "questiontable dispatcher: " + sentinel}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Save and restore global log output.
|
||||
originalOutput := log.Writer()
|
||||
originalFlags := log.Flags()
|
||||
var buf strings.Builder
|
||||
log.SetOutput(&buf)
|
||||
log.SetFlags(0)
|
||||
defer func() {
|
||||
log.SetOutput(originalOutput)
|
||||
log.SetFlags(originalFlags)
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
|
||||
}()
|
||||
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
cancel()
|
||||
wg.Wait()
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "question table dispatch failed") {
|
||||
t.Errorf("log must contain stable stage text, got: %s", output)
|
||||
}
|
||||
if strings.Contains(output, sentinel) {
|
||||
t.Errorf("log must not contain sentinel %q, got: %s", sentinel, output)
|
||||
}
|
||||
}
|
||||
|
||||
// sentinelErrorPoller always returns an error with the given message.
|
||||
type sentinelErrorPoller struct {
|
||||
errMsg string
|
||||
}
|
||||
|
||||
func (r *sentinelErrorPoller) RunOnce(ctx context.Context) (questiontable.DispatcherStats, error) {
|
||||
return questiontable.DispatcherStats{}, errors.New(r.errMsg)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 6: nil/typed-nil runner and nonpositive interval safe
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRunQuestionTablePollerNilRunner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
// Should not panic, just return immediately.
|
||||
runQuestionTablePoller(ctx, nil, 50*time.Millisecond)
|
||||
// If we reach here without panic, test passes.
|
||||
}
|
||||
|
||||
func TestRunQuestionTablePollerNonPositiveInterval(t *testing.T) {
|
||||
runner := &countingPollerRunner{}
|
||||
ctx := context.Background()
|
||||
runQuestionTablePoller(ctx, runner, 0)
|
||||
runQuestionTablePoller(ctx, runner, -1*time.Second)
|
||||
|
||||
runner.mu.Lock()
|
||||
calls := runner.calls
|
||||
runner.mu.Unlock()
|
||||
|
||||
if calls != 0 {
|
||||
t.Errorf("expected 0 calls for non-positive interval, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQuestionTablePollerTypedNilInterface(t *testing.T) {
|
||||
// The runner is nil through the interface but not at the concrete type level.
|
||||
var typedNil *questiontable.Dispatcher = nil
|
||||
var runner questionTablePollerRunner = typedNil
|
||||
ctx := context.Background()
|
||||
// Should not panic, just return immediately.
|
||||
runQuestionTablePoller(ctx, runner, 50*time.Millisecond)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 7: main source wiring contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMainWiresQuestionTableEnvNames(t *testing.T) {
|
||||
mainData, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
syncData, err := os.ReadFile("question_table_sync.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainSource := string(mainData)
|
||||
syncSource := string(syncData)
|
||||
|
||||
for _, fragment := range []string{
|
||||
`"DINGTALK_QUESTION_TABLE_BASE_ID"`,
|
||||
`"DINGTALK_QUESTION_TABLE_SHEET_ID"`,
|
||||
`"DINGTALK_QUESTION_TABLE_OPERATOR_ID"`,
|
||||
} {
|
||||
if !strings.Contains(syncSource, fragment) {
|
||||
t.Errorf("question table config reader missing %q", fragment)
|
||||
}
|
||||
}
|
||||
|
||||
for _, fragment := range []string{
|
||||
`NewQuestionTableExportRepository`,
|
||||
`5*time.Second`,
|
||||
`"DingTalk question table dispatcher started"`,
|
||||
} {
|
||||
if !strings.Contains(mainSource, fragment) {
|
||||
t.Errorf("main wiring missing %q", fragment)
|
||||
}
|
||||
}
|
||||
|
||||
shutdownAt := strings.Index(mainSource, "server.Shutdown(shutdownCtx)")
|
||||
waitAt := strings.Index(mainSource, "case <-questionTableDone:")
|
||||
if shutdownAt < 0 || waitAt < 0 || waitAt <= shutdownAt {
|
||||
t.Fatal("main must wait for question table poller after HTTP shutdown and before returning")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test doubles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// questionTableExportRepoStub implements questiontable.ExportRepository.
|
||||
type questionTableExportRepoStub struct{}
|
||||
|
||||
func (r *questionTableExportRepoStub) Claim(ctx context.Context, limit int, maxAttempts int, staleBefore time.Time) ([]questiontable.PendingExport, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *questionTableExportRepoStub) MarkExported(ctx context.Context, exportID, location string) error {
|
||||
return nil
|
||||
}
|
||||
func (r *questionTableExportRepoStub) MarkFailed(ctx context.Context, exportID, failureCode string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// countingPollerRunner counts calls to RunOnce.
|
||||
type countingPollerRunner struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *countingPollerRunner) RunOnce(ctx context.Context) (questiontable.DispatcherStats, error) {
|
||||
r.mu.Lock()
|
||||
r.calls++
|
||||
r.mu.Unlock()
|
||||
return questiontable.DispatcherStats{}, nil
|
||||
}
|
||||
|
||||
// errorThenSuccessPoller returns an error on the first call, then succeeds.
|
||||
type errorThenSuccessPoller struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *errorThenSuccessPoller) RunOnce(ctx context.Context) (questiontable.DispatcherStats, error) {
|
||||
r.mu.Lock()
|
||||
r.calls++
|
||||
call := r.calls
|
||||
r.mu.Unlock()
|
||||
|
||||
if call == 1 {
|
||||
return questiontable.DispatcherStats{}, errors.New("questiontable dispatcher: claim failed")
|
||||
}
|
||||
return questiontable.DispatcherStats{Claimed: 1, Exported: 1}, nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
|
||||
)
|
||||
|
||||
type validatedSettingsRepository struct {
|
||||
*postgres.SettingsRepository
|
||||
clientID string
|
||||
secret string
|
||||
testSupport func(context.Context, settings.ResourceBinding) error
|
||||
validateTable func(context.Context, settings.ResourceBinding) (map[string]string, error)
|
||||
saveBinding func(context.Context, settings.ResourceBinding) error
|
||||
restart func()
|
||||
restartDelay time.Duration
|
||||
}
|
||||
|
||||
func (r *validatedSettingsRepository) TestAndActivateSupportGroup(ctx context.Context, binding settings.ResourceBinding) error {
|
||||
if r.testSupport != nil {
|
||||
if err := r.testSupport(ctx, binding); err != nil {
|
||||
return errors.New("support_group_test_failed")
|
||||
}
|
||||
} else {
|
||||
sender, err := dingtalk.NewSupportGroupSender(r.clientID, r.secret, binding.ConversationID, nil)
|
||||
if err != nil {
|
||||
return errors.New("support_group_test_invalid_config")
|
||||
}
|
||||
if err = sender.SendTest(ctx); err != nil {
|
||||
return errors.New("support_group_test_failed")
|
||||
}
|
||||
}
|
||||
binding.ValidationCode = "support_test_ok"
|
||||
binding.Activate = true
|
||||
var err error
|
||||
if r.saveBinding != nil {
|
||||
err = r.saveBinding(ctx, binding)
|
||||
} else if r.SettingsRepository != nil {
|
||||
err = r.SettingsRepository.SaveBinding(ctx, binding)
|
||||
} else {
|
||||
err = errors.New("support_group_binding_repository_unavailable")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.restart != nil {
|
||||
go func() {
|
||||
if r.restartDelay > 0 {
|
||||
time.Sleep(r.restartDelay)
|
||||
}
|
||||
r.restart()
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *validatedSettingsRepository) SaveBinding(ctx context.Context, binding settings.ResourceBinding) error {
|
||||
activate := false
|
||||
switch binding.Kind {
|
||||
case "question_table", "faq_table":
|
||||
var mapping map[string]string
|
||||
var err error
|
||||
if r.validateTable != nil {
|
||||
mapping, err = r.validateTable(ctx, binding)
|
||||
} else {
|
||||
mapping, err = r.readAndMapTable(ctx, binding)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
binding.FieldMapping = mapping
|
||||
binding.ValidationCode = "schema_ok"
|
||||
binding.Activate = true
|
||||
activate = true
|
||||
case "support_group":
|
||||
binding.ValidationCode = "pending_test_message"
|
||||
binding.Activate = false
|
||||
default:
|
||||
return errors.New("unsupported_binding_kind")
|
||||
}
|
||||
var err error
|
||||
if r.saveBinding != nil {
|
||||
err = r.saveBinding(ctx, binding)
|
||||
} else if r.SettingsRepository != nil {
|
||||
err = r.SettingsRepository.SaveBinding(ctx, binding)
|
||||
} else {
|
||||
err = errors.New("binding_repository_unavailable")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if activate {
|
||||
r.requestRestart()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *validatedSettingsRepository) readAndMapTable(ctx context.Context, binding settings.ResourceBinding) (map[string]string, error) {
|
||||
client, err := questiontable.NewClient(questiontable.Config{
|
||||
ClientID: r.clientID, Secret: r.secret, BaseID: binding.BaseID,
|
||||
SheetID: binding.SheetID, OperatorID: binding.OperatorID,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("table_binding_invalid_config")
|
||||
}
|
||||
fields, err := client.ReadFields(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
required := questiontable.QuestionSchema()
|
||||
if binding.Kind == "faq_table" {
|
||||
required = questiontable.FAQSchema()
|
||||
}
|
||||
return questiontable.MapRequiredFields(fields, required)
|
||||
}
|
||||
|
||||
func (r *validatedSettingsRepository) requestRestart() {
|
||||
if r.restart == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if r.restartDelay > 0 {
|
||||
time.Sleep(r.restartDelay)
|
||||
}
|
||||
r.restart()
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||
)
|
||||
|
||||
func TestTableBindingRestartsOnlyAfterActiveSave(t *testing.T) {
|
||||
restarted := make(chan struct{}, 1)
|
||||
var saved settings.ResourceBinding
|
||||
repo := &validatedSettingsRepository{
|
||||
validateTable: func(_ context.Context, binding settings.ResourceBinding) (map[string]string, error) {
|
||||
return map[string]string{"answer": "field-answer"}, nil
|
||||
},
|
||||
saveBinding: func(_ context.Context, binding settings.ResourceBinding) error {
|
||||
saved = binding
|
||||
return nil
|
||||
},
|
||||
restart: func() { restarted <- struct{}{} },
|
||||
restartDelay: 0,
|
||||
}
|
||||
if err := repo.SaveBinding(context.Background(), settings.ResourceBinding{Kind: "question_table", TenantKey: "tenant", BaseID: "base", SheetID: "sheet", OperatorID: "operator"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !saved.Activate || saved.ValidationCode != "schema_ok" || saved.FieldMapping["answer"] != "field-answer" {
|
||||
t.Fatalf("binding=%+v", saved)
|
||||
}
|
||||
select {
|
||||
case <-restarted:
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
t.Fatal("restart not requested")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableBindingDoesNotRestartOnValidationOrSaveFailure(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
validateErr error
|
||||
saveErr error
|
||||
}{
|
||||
{name: "validation failure", validateErr: errors.New("schema failed")},
|
||||
{name: "save failure", saveErr: errors.New("save failed")},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
restarted := make(chan struct{}, 1)
|
||||
repo := &validatedSettingsRepository{
|
||||
validateTable: func(context.Context, settings.ResourceBinding) (map[string]string, error) {
|
||||
return map[string]string{"answer": "field"}, tc.validateErr
|
||||
},
|
||||
saveBinding: func(context.Context, settings.ResourceBinding) error { return tc.saveErr },
|
||||
restart: func() { restarted <- struct{}{} },
|
||||
}
|
||||
if err := repo.SaveBinding(context.Background(), settings.ResourceBinding{Kind: "question_table"}); err == nil {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
select {
|
||||
case <-restarted:
|
||||
t.Fatal("must not restart after failed activation")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportGroupActivationRestartsOnlyAfterTestAndSaveSucceed(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
testErr error
|
||||
saveErr error
|
||||
wantSaved bool
|
||||
wantRestart bool
|
||||
}{
|
||||
{name: "test fails", testErr: errors.New("send failed")},
|
||||
{name: "save fails", saveErr: errors.New("save failed"), wantSaved: true},
|
||||
{name: "success", wantSaved: true, wantRestart: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var saved settings.ResourceBinding
|
||||
restarted := make(chan struct{}, 1)
|
||||
repo := &validatedSettingsRepository{
|
||||
testSupport: func(context.Context, settings.ResourceBinding) error { return tc.testErr },
|
||||
saveBinding: func(_ context.Context, binding settings.ResourceBinding) error {
|
||||
saved = binding
|
||||
return tc.saveErr
|
||||
},
|
||||
restart: func() { restarted <- struct{}{} },
|
||||
restartDelay: 0,
|
||||
}
|
||||
err := repo.TestAndActivateSupportGroup(context.Background(), settings.ResourceBinding{
|
||||
Kind: "support_group", TenantKey: "tenant", ConversationID: "group",
|
||||
})
|
||||
if (err != nil) != (tc.testErr != nil || tc.saveErr != nil) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := saved.Kind != ""; got != tc.wantSaved {
|
||||
t.Fatalf("saved=%v want=%v", got, tc.wantSaved)
|
||||
}
|
||||
if tc.wantSaved && (saved.ValidationCode != "support_test_ok" || !saved.Activate) {
|
||||
t.Fatalf("binding=%+v", saved)
|
||||
}
|
||||
select {
|
||||
case <-restarted:
|
||||
if !tc.wantRestart {
|
||||
t.Fatal("unexpected restart")
|
||||
}
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
if tc.wantRestart {
|
||||
t.Fatal("restart not requested")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"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"
|
||||
)
|
||||
|
||||
type dingTalkSettingsRouter struct{ controller *settings.Controller }
|
||||
|
||||
func (r *dingTalkSettingsRouter) Handle(ctx context.Context, m dingtalk.SettingsMessage) (bool, string, error) {
|
||||
if r == nil || r.controller == nil {
|
||||
return false, "", nil
|
||||
}
|
||||
if !r.controller.ShouldHandle(ctx, m.TenantKey, m.StaffID, m.Text) {
|
||||
return false, "", nil
|
||||
}
|
||||
out, err := r.controller.Handle(ctx, settings.SettingInput{TenantKey: m.TenantKey, Channel: m.Channel, StaffID: m.StaffID, UnionID: m.UnionID, DisplayName: m.DisplayName, Department: m.Department, Text: m.Text, ConversationType: m.ConversationType, ConversationID: m.ConversationID, ConversationTitle: m.ConversationTitle})
|
||||
return true, strings.TrimSpace(out), err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func buildSettingsRuntime(ctx context.Context, store *postgres.Store, tenantKey string) (*settingsRuntime, error) {
|
||||
tenantKey = strings.TrimSpace(tenantKey)
|
||||
if tenantKey == "" {
|
||||
return nil, errors.New("DOBOTAI_TENANT_KEY is required")
|
||||
}
|
||||
repo, err := postgres.NewSettingsRepository(store)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root := strings.TrimSpace(os.Getenv("DOBOTAI_SECRET_DIR"))
|
||||
if root == "" {
|
||||
home, homeErr := os.UserHomeDir()
|
||||
if homeErr != nil {
|
||||
return nil, homeErr
|
||||
}
|
||||
root = filepath.Join(home, ".config", "dobotai", "secrets")
|
||||
}
|
||||
secrets, err := secretstore.New(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pendingActivation, err := repo.PrepareDingTalkRuntimeStart(ctx, tenantKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientID, secretRef, _, err := repo.ActiveDingTalkConfig(ctx, tenantKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var secret string
|
||||
if secretRef != "" {
|
||||
secret, err = secrets.Get(secretRef)
|
||||
if err != nil {
|
||||
return nil, errors.New("active DingTalk secret is unavailable")
|
||||
}
|
||||
}
|
||||
if clientID == "" && strings.TrimSpace(os.Getenv("DINGTALK_CLIENT_ID")) != "" && strings.TrimSpace(os.Getenv("DINGTALK_CLIENT_SECRET")) != "" {
|
||||
clientID, secret = strings.TrimSpace(os.Getenv("DINGTALK_CLIENT_ID")), strings.TrimSpace(os.Getenv("DINGTALK_CLIENT_SECRET"))
|
||||
secretRef, err = secrets.Put("dingtalk", secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = repo.SaveDingTalkConfig(ctx, tenantKey, clientID, secretRef, true); err != nil {
|
||||
_ = secrets.Delete(secretRef)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
|
||||
)
|
||||
|
||||
type supportDispatcherRunner interface {
|
||||
RunOnce(context.Context) (coverage.DispatcherStats, error)
|
||||
}
|
||||
|
||||
func buildSupportDispatcher(repo coverage.NotificationRepository, clientID, clientSecret, openConversationID string, recordLinks *faqtable.RecordLinkBuilder) (*coverage.Dispatcher, error) {
|
||||
openConversationID = strings.TrimSpace(openConversationID)
|
||||
if openConversationID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if repo == nil {
|
||||
return nil, errors.New("support notification repository is required")
|
||||
}
|
||||
if recordLinks == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sender, err := dingtalk.NewSupportGroupSender(clientID, clientSecret, openConversationID, nil, dingtalk.WithFAQRecordLinkBuilder(recordLinks))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return coverage.NewDispatcher(repo, sender)
|
||||
}
|
||||
|
||||
func runSupportDispatcher(ctx context.Context, dispatcher supportDispatcherRunner, interval time.Duration) {
|
||||
if dispatcher == nil || interval <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
runOnce := func() {
|
||||
runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
stats, err := dispatcher.RunOnce(runCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("support notification dispatch failed: %v", err)
|
||||
return
|
||||
}
|
||||
if stats.Claimed > 0 {
|
||||
log.Printf("support notification dispatch: claimed=%d notified=%d failed=%d", stats.Claimed, stats.Notified, stats.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
runOnce()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
|
||||
)
|
||||
|
||||
type supportNotificationRepoStub struct{}
|
||||
|
||||
func (*supportNotificationRepoStub) Claim(context.Context, int, int, time.Time) ([]coverage.PendingSupportCase, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*supportNotificationRepoStub) MarkNotified(context.Context, string) error { return nil }
|
||||
func (*supportNotificationRepoStub) MarkFailed(context.Context, string, string) error { return nil }
|
||||
|
||||
func testFAQRecordLinks(t *testing.T) *faqtable.RecordLinkBuilder {
|
||||
t.Helper()
|
||||
builder, err := faqtable.NewRecordLinkBuilder("base", "sheet", "view")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
type supportDispatcherStub struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
errs []error
|
||||
callCh chan int
|
||||
}
|
||||
|
||||
func (s *supportDispatcherStub) RunOnce(context.Context) (coverage.DispatcherStats, error) {
|
||||
s.mu.Lock()
|
||||
s.calls++
|
||||
call := s.calls
|
||||
var err error
|
||||
if call <= len(s.errs) {
|
||||
err = s.errs[call-1]
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if s.callCh != nil {
|
||||
select {
|
||||
case s.callCh <- call:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return coverage.DispatcherStats{Claimed: 1, Notified: 1}, err
|
||||
}
|
||||
|
||||
func TestBuildSupportDispatcherDisabledWithoutGroup(t *testing.T) {
|
||||
dispatcher, err := buildSupportDispatcher(nil, "", "", " ", nil)
|
||||
if err != nil || dispatcher != nil {
|
||||
t.Fatalf("dispatcher=%v err=%v, want disabled", dispatcher, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSupportDispatcherBlocksWithoutFAQLinks(t *testing.T) {
|
||||
dispatcher, err := buildSupportDispatcher(&supportNotificationRepoStub{}, "client", "secret", "group", nil)
|
||||
if err != nil || dispatcher != nil {
|
||||
t.Fatalf("dispatcher=%v err=%v, want blocked without process failure", dispatcher, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSupportDispatcherEnabledFailsClosed(t *testing.T) {
|
||||
repo := &supportNotificationRepoStub{}
|
||||
links := testFAQRecordLinks(t)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
repo coverage.NotificationRepository
|
||||
clientID, secret, gid string
|
||||
links *faqtable.RecordLinkBuilder
|
||||
}{
|
||||
{name: "missing repo", clientID: "client", secret: "secret", gid: "group", links: links},
|
||||
{name: "missing client", repo: repo, secret: "secret", gid: "group", links: links},
|
||||
{name: "missing secret", repo: repo, clientID: "client", gid: "group", links: links},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := buildSupportDispatcher(tc.repo, tc.clientID, tc.secret, tc.gid, tc.links); err == nil {
|
||||
t.Fatal("expected configuration error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSupportDispatcherValidDoesNotSend(t *testing.T) {
|
||||
dispatcher, err := buildSupportDispatcher(&supportNotificationRepoStub{}, " client ", " secret ", " group ", testFAQRecordLinks(t))
|
||||
if err != nil {
|
||||
t.Fatalf("buildSupportDispatcher: %v", err)
|
||||
}
|
||||
if dispatcher == nil {
|
||||
t.Fatal("expected dispatcher")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSupportDispatcherRunsImmediatelyAndStops(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
stub := &supportDispatcherStub{callCh: make(chan int, 4)}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
runSupportDispatcher(ctx, stub, time.Hour)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-stub.callCh:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("dispatcher did not run immediately")
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("dispatcher did not stop after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSupportDispatcherContinuesAfterError(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
stub := &supportDispatcherStub{
|
||||
errs: []error{errors.New("temporary db error")},
|
||||
callCh: make(chan int, 4),
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
runSupportDispatcher(ctx, stub, 10*time.Millisecond)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
for want := 1; want <= 2; want++ {
|
||||
select {
|
||||
case got := <-stub.callCh:
|
||||
if got != want {
|
||||
t.Fatalf("call=%d, want %d", got, want)
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatalf("missing dispatcher call %d", want)
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestRunSupportDispatcherRejectsNonPositiveInterval(t *testing.T) {
|
||||
stub := &supportDispatcherStub{}
|
||||
runSupportDispatcher(context.Background(), stub, 0)
|
||||
stub.mu.Lock()
|
||||
defer stub.mu.Unlock()
|
||||
if stub.calls != 0 {
|
||||
t.Fatalf("calls=%d, want 0", stub.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainWiresCoverageSupportNotifications(t *testing.T) {
|
||||
data, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(data)
|
||||
for _, fragment := range []string{
|
||||
`os.Getenv("DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID")`,
|
||||
`coverage.NewService(store)`,
|
||||
`qa.WithSucceededTurnObserver`,
|
||||
`case <-runtimeReady:`,
|
||||
`runSupportDispatcher(ctx, supportDispatcher, 5*time.Second)`,
|
||||
} {
|
||||
if !strings.Contains(source, fragment) {
|
||||
t.Errorf("main support notification wiring missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 000006_native_card_feedback_reasons down migration
|
||||
-- A schema rollback cannot retain values rejected by the legacy constraint,
|
||||
-- so map each native reason to its closest B2 legacy category first.
|
||||
|
||||
UPDATE answer_feedback
|
||||
SET reason_code = CASE reason_code
|
||||
WHEN 'content_mismatch' THEN 'irrelevant'
|
||||
WHEN 'content_incomplete' THEN 'not_resolved'
|
||||
WHEN 'illogical_expression' THEN 'unclear'
|
||||
WHEN 'factual_error' THEN 'incorrect'
|
||||
WHEN 'sensitive_content' THEN 'other'
|
||||
WHEN 'answer_not_wanted' THEN 'not_resolved'
|
||||
ELSE reason_code
|
||||
END
|
||||
WHERE reason_code IN (
|
||||
'content_mismatch',
|
||||
'content_incomplete',
|
||||
'illogical_expression',
|
||||
'factual_error',
|
||||
'sensitive_content',
|
||||
'answer_not_wanted'
|
||||
);
|
||||
|
||||
ALTER TABLE answer_feedback
|
||||
DROP CONSTRAINT IF EXISTS answer_feedback_reason_code_check;
|
||||
|
||||
ALTER TABLE answer_feedback
|
||||
ADD CONSTRAINT answer_feedback_reason_code_check
|
||||
CHECK (reason_code IN ('', 'incorrect', 'not_resolved', 'irrelevant', 'unclear', 'outdated', 'other'));
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 000006_native_card_feedback_reasons up migration
|
||||
-- Preserve the original B2 reasons and add the six exact reason codes emitted
|
||||
-- by DingTalk's built-in AI feedback component.
|
||||
|
||||
ALTER TABLE answer_feedback
|
||||
DROP CONSTRAINT IF EXISTS answer_feedback_reason_code_check;
|
||||
|
||||
ALTER TABLE answer_feedback
|
||||
ADD CONSTRAINT answer_feedback_reason_code_check
|
||||
CHECK (reason_code IN (
|
||||
'',
|
||||
'incorrect',
|
||||
'not_resolved',
|
||||
'irrelevant',
|
||||
'unclear',
|
||||
'outdated',
|
||||
'other',
|
||||
'content_mismatch',
|
||||
'content_incomplete',
|
||||
'illogical_expression',
|
||||
'factual_error',
|
||||
'sensitive_content',
|
||||
'answer_not_wanted'
|
||||
));
|
||||
@@ -0,0 +1,78 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func read006Migration(t *testing.T, direction string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(".", "000006_native_card_feedback_reasons."+direction+".sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read 000006 %s migration: %v", direction, err)
|
||||
}
|
||||
return strings.Join(strings.Fields(strings.ToUpper(string(data))), " ")
|
||||
}
|
||||
|
||||
func TestUp006ExpandsNamedReasonConstraint(t *testing.T) {
|
||||
up := read006Migration(t, "up")
|
||||
for _, fragment := range []string{
|
||||
"ALTER TABLE ANSWER_FEEDBACK DROP CONSTRAINT IF EXISTS ANSWER_FEEDBACK_REASON_CODE_CHECK",
|
||||
"ADD CONSTRAINT ANSWER_FEEDBACK_REASON_CODE_CHECK",
|
||||
"CHECK (REASON_CODE IN",
|
||||
"CONTENT_MISMATCH",
|
||||
"CONTENT_INCOMPLETE",
|
||||
"ILLOGICAL_EXPRESSION",
|
||||
"FACTUAL_ERROR",
|
||||
"SENSITIVE_CONTENT",
|
||||
"ANSWER_NOT_WANTED",
|
||||
} {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing %q", fragment)
|
||||
}
|
||||
}
|
||||
for _, legacy := range []string{"INCORRECT", "NOT_RESOLVED", "IRRELEVANT", "UNCLEAR", "OUTDATED", "OTHER"} {
|
||||
if !strings.Contains(up, legacy) {
|
||||
t.Errorf("up migration must preserve legacy reason %q", legacy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp006OnlyChangesReasonConstraint(t *testing.T) {
|
||||
up := read006Migration(t, "up")
|
||||
for _, forbidden := range []string{"DROP TABLE", "DROP COLUMN", "TRUNCATE", "DELETE FROM", "UPDATE ANSWER_FEEDBACK"} {
|
||||
if strings.Contains(up, forbidden) {
|
||||
t.Errorf("up migration contains forbidden operation %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown006MapsNativeReasonsBeforeRestoringConstraint(t *testing.T) {
|
||||
down := read006Migration(t, "down")
|
||||
mappings := map[string]string{
|
||||
"CONTENT_MISMATCH": "IRRELEVANT",
|
||||
"CONTENT_INCOMPLETE": "NOT_RESOLVED",
|
||||
"ILLOGICAL_EXPRESSION": "UNCLEAR",
|
||||
"FACTUAL_ERROR": "INCORRECT",
|
||||
"SENSITIVE_CONTENT": "OTHER",
|
||||
"ANSWER_NOT_WANTED": "NOT_RESOLVED",
|
||||
}
|
||||
updateAt := strings.Index(down, "UPDATE ANSWER_FEEDBACK")
|
||||
constraintAt := strings.Index(down, "ADD CONSTRAINT ANSWER_FEEDBACK_REASON_CODE_CHECK")
|
||||
if updateAt < 0 || constraintAt < 0 || updateAt > constraintAt {
|
||||
t.Fatal("down migration must map native reasons before restoring the legacy constraint")
|
||||
}
|
||||
for from, to := range mappings {
|
||||
if !strings.Contains(down, "WHEN '"+from+"' THEN '"+to+"'") {
|
||||
t.Errorf("down migration missing mapping %s -> %s", from, to)
|
||||
}
|
||||
}
|
||||
for _, native := range []string{"CONTENT_MISMATCH", "CONTENT_INCOMPLETE", "ILLOGICAL_EXPRESSION", "FACTUAL_ERROR", "SENSITIVE_CONTENT", "ANSWER_NOT_WANTED"} {
|
||||
constraint := down[constraintAt:]
|
||||
if strings.Contains(constraint, native) {
|
||||
t.Errorf("legacy down constraint must exclude native reason %q", native)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 000007_coverage_decisions down migration
|
||||
-- Drop support_cases first (child table), then coverage_decisions (parent table).
|
||||
|
||||
DROP TABLE IF EXISTS support_cases;
|
||||
DROP TABLE IF EXISTS coverage_decisions;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- 000007_coverage_decisions up migration
|
||||
-- Creates coverage_decisions and support_cases tables for B3.1 persistence.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coverage_decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL REFERENCES turns(id),
|
||||
evaluator_version TEXT NOT NULL DEFAULT 'v1',
|
||||
verdict TEXT NOT NULL CHECK (verdict IN ('covered', 'uncovered')),
|
||||
reason_code TEXT NOT NULL CHECK (reason_code IN (
|
||||
'covered_has_references',
|
||||
'uncovered_no_references',
|
||||
'uncovered_explicit_no_answer'
|
||||
)),
|
||||
reference_count INTEGER NOT NULL DEFAULT 0 CHECK (reference_count >= 0),
|
||||
top_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_key, channel, turn_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_coverage_decisions_tenant_channel
|
||||
ON coverage_decisions (tenant_key, channel, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_coverage_decisions_verdict
|
||||
ON coverage_decisions (verdict) WHERE verdict = 'uncovered';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS support_cases (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL REFERENCES turns(id),
|
||||
coverage_decision_id TEXT NOT NULL REFERENCES coverage_decisions(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'notifying', 'notification_failed', 'notified', 'resolved')),
|
||||
notification_attempts INTEGER NOT NULL DEFAULT 0 CHECK (notification_attempts >= 0),
|
||||
last_notification_failure_code TEXT NOT NULL DEFAULT '',
|
||||
last_notification_at TIMESTAMPTZ,
|
||||
notified_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_key, channel, turn_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_support_cases_notification_pending
|
||||
ON support_cases (status, created_at, id) WHERE status IN ('pending', 'notification_failed');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_support_cases_notifying_stale
|
||||
ON support_cases (status, last_notification_at) WHERE status = 'notifying';
|
||||
@@ -0,0 +1,179 @@
|
||||
// Package migrations_test contains source-level contract tests for the
|
||||
// 000007_coverage_decisions migration. No real database connection is used.
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func read007Migration(t *testing.T, direction string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(".", "000007_coverage_decisions."+direction+".sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read 000007 %s migration: %v", direction, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestUp007CreatesCoverageDecisionsTable(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
required := []string{
|
||||
"CREATE TABLE IF NOT EXISTS COVERAGE_DECISIONS",
|
||||
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
|
||||
"EVALUATOR_VERSION", "VERDICT", "REASON_CODE", "REFERENCE_COUNT", "TOP_SCORE",
|
||||
"CREATED_AT", "UPDATED_AT",
|
||||
}
|
||||
for _, fragment := range required {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007CoverageDecisionsConstraints(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
// REFERENCES turns(id)
|
||||
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
|
||||
t.Error("coverage_decisions.turn_id must reference turns(id)")
|
||||
}
|
||||
// UNIQUE (tenant_key, channel, turn_id)
|
||||
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
|
||||
t.Error("coverage_decisions must have UNIQUE (tenant_key, channel, turn_id)")
|
||||
}
|
||||
// verdict CHECK
|
||||
if !strings.Contains(up, "COVERED") || !strings.Contains(up, "UNCOVERED") {
|
||||
t.Error("verdict CHECK must include 'covered' and 'uncovered'")
|
||||
}
|
||||
// reason_code CHECK
|
||||
for _, code := range []string{"COVERED_HAS_REFERENCES", "UNCOVERED_NO_REFERENCES", "UNCOVERED_EXPLICIT_NO_ANSWER"} {
|
||||
if !strings.Contains(up, code) {
|
||||
t.Errorf("reason_code CHECK missing %q", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007CreatesSupportCasesTable(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
required := []string{
|
||||
"CREATE TABLE IF NOT EXISTS SUPPORT_CASES",
|
||||
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
|
||||
"COVERAGE_DECISION_ID", "STATUS", "CREATED_AT", "UPDATED_AT",
|
||||
}
|
||||
for _, fragment := range required {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing %q", fragment)
|
||||
}
|
||||
}
|
||||
// Notification fields must be present
|
||||
notificationFields := []string{
|
||||
"NOTIFICATION_ATTEMPTS", "LAST_NOTIFICATION_FAILURE_CODE",
|
||||
"LAST_NOTIFICATION_AT", "NOTIFIED_AT",
|
||||
}
|
||||
for _, fragment := range notificationFields {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing notification field %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007SupportCasesConstraints(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
// FK to coverage_decisions
|
||||
if !strings.Contains(up, "REFERENCES COVERAGE_DECISIONS(ID)") {
|
||||
t.Error("support_cases.coverage_decision_id must reference coverage_decisions(id)")
|
||||
}
|
||||
// FK to turns
|
||||
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
|
||||
t.Error("support_cases.turn_id must reference turns(id)")
|
||||
}
|
||||
// UNIQUE (tenant_key, channel, turn_id)
|
||||
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
|
||||
t.Error("support_cases must have UNIQUE (tenant_key, channel, turn_id)")
|
||||
}
|
||||
// status CHECK
|
||||
for _, status := range []string{"PENDING", "NOTIFYING", "NOTIFICATION_FAILED", "NOTIFIED", "RESOLVED"} {
|
||||
if !strings.Contains(up, status) {
|
||||
t.Errorf("status CHECK must include %q", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007CreatesQueryIndexes(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
// Pending/retry query index and stale notifying index
|
||||
if !strings.Contains(up, "IDX_SUPPORT_CASES_NOTIFICATION_PENDING") ||
|
||||
!strings.Contains(up, "WHERE STATUS IN ('PENDING', 'NOTIFICATION_FAILED')") {
|
||||
t.Error("up migration must index pending and notification_failed support_cases")
|
||||
}
|
||||
if !strings.Contains(up, "IDX_SUPPORT_CASES_NOTIFYING_STALE") ||
|
||||
!strings.Contains(up, "WHERE STATUS = 'NOTIFYING'") {
|
||||
t.Error("up migration must index stale notifying support_cases")
|
||||
}
|
||||
// Coverage decisions listing index
|
||||
if !strings.Contains(up, "IDX_COVERAGE_DECISIONS_TENANT_CHANNEL") {
|
||||
t.Error("up migration must create index on coverage_decisions (tenant_key, channel, created_at DESC)")
|
||||
}
|
||||
// Uncovered verdict index
|
||||
if !strings.Contains(up, "WHERE VERDICT = 'UNCOVERED'") {
|
||||
t.Error("up migration must create partial index on uncovered coverage_decisions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007CoverageNoReservedWords(t *testing.T) {
|
||||
up := read007Migration(t, "up")
|
||||
// Check for bare "references" as column name in CREATE TABLE statements
|
||||
lines := strings.Split(up, "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
upperLine := strings.ToUpper(trimmed)
|
||||
if strings.HasPrefix(upperLine, "REFERENCES ") && !strings.Contains(upperLine, "REFERENCES TURNS") && !strings.Contains(upperLine, "REFERENCES COVERAGE") {
|
||||
t.Errorf("reserved word 'references' used as column name in: %s", trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007NoForbiddenOperations(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
for _, forbidden := range []string{
|
||||
"DROP TABLE", "DROP COLUMN", "TRUNCATE", "DELETE FROM",
|
||||
"ALTER TABLE", "RAW_PAYLOAD", "SESSIONWEBHOOK", "TOKEN",
|
||||
"RAW_RESPONSE", "RAW_ERROR",
|
||||
} {
|
||||
if strings.Contains(up, forbidden) {
|
||||
t.Errorf("up migration contains forbidden operation %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp007NoQuestionAnswerBodies(t *testing.T) {
|
||||
up := strings.ToUpper(read007Migration(t, "up"))
|
||||
for _, forbidden := range []string{"QUESTION", "ANSWER_TEXT", "REFERENCE_CONTENT", "ANSWER_CONTENT"} {
|
||||
if strings.Contains(up, forbidden) {
|
||||
t.Errorf("coverage/support tables must not duplicate question/answer/reference bodies: found %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown007DropsInSafeOrder(t *testing.T) {
|
||||
down := strings.ToUpper(read007Migration(t, "down"))
|
||||
supportAt := strings.Index(down, "DROP TABLE IF EXISTS SUPPORT_CASES")
|
||||
coverageAt := strings.Index(down, "DROP TABLE IF EXISTS COVERAGE_DECISIONS")
|
||||
if supportAt < 0 || coverageAt < 0 {
|
||||
t.Fatal("down migration must drop both tables")
|
||||
}
|
||||
if supportAt > coverageAt {
|
||||
t.Fatal("down migration must drop support_cases (child) before coverage_decisions (parent)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown007DoesNotDropBaselineTables(t *testing.T) {
|
||||
down := strings.ToUpper(read007Migration(t, "down"))
|
||||
for _, table := range []string{"TURNS", "MESSAGES", "SESSION_MAPPINGS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS"} {
|
||||
if strings.Contains(down, "DROP TABLE IF EXISTS "+table) {
|
||||
t.Errorf("down migration must not drop baseline table %q", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE session_mappings
|
||||
DROP COLUMN IF EXISTS display_title;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE session_mappings
|
||||
ADD COLUMN IF NOT EXISTS display_title TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,28 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionDisplayTitleMigration(t *testing.T) {
|
||||
up, err := os.ReadFile(filepath.Join("000008_session_display_title.up.sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read up migration: %v", err)
|
||||
}
|
||||
down, err := os.ReadFile(filepath.Join("000008_session_display_title.down.sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read down migration: %v", err)
|
||||
}
|
||||
upper := strings.ToUpper(string(up))
|
||||
for _, required := range []string{"ALTER TABLE SESSION_MAPPINGS", "ADD COLUMN IF NOT EXISTS DISPLAY_TITLE", "TEXT NOT NULL", "DEFAULT ''"} {
|
||||
if !strings.Contains(upper, required) {
|
||||
t.Errorf("up migration missing %q", required)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.ToUpper(string(down)), "DROP COLUMN IF EXISTS DISPLAY_TITLE") {
|
||||
t.Error("down migration must drop display_title")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE messages
|
||||
DROP COLUMN IF EXISTS region_snapshot;
|
||||
|
||||
ALTER TABLE messages
|
||||
DROP COLUMN IF EXISTS department_snapshot;
|
||||
|
||||
DROP TABLE IF EXISTS directory_profiles;
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE IF NOT EXISTS directory_profiles (
|
||||
tenant_key TEXT NOT NULL,
|
||||
staff_id TEXT NOT NULL,
|
||||
union_id TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
nickname TEXT NOT NULL DEFAULT '',
|
||||
job_title TEXT NOT NULL DEFAULT '',
|
||||
department_ids JSONB NOT NULL DEFAULT '[]'::JSONB,
|
||||
department_name TEXT NOT NULL DEFAULT '',
|
||||
main_department_name TEXT NOT NULL DEFAULT '',
|
||||
region TEXT NOT NULL DEFAULT '',
|
||||
employment_status TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL CHECK (source IN ('full_sync','on_demand')),
|
||||
synced_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tenant_key, staff_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_directory_profiles_tenant_name
|
||||
ON directory_profiles (tenant_key, name);
|
||||
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS department_snapshot TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS region_snapshot TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,42 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDirectoryProfilesMigration(t *testing.T) {
|
||||
up, err := os.ReadFile(filepath.Join("000009_directory_profiles.up.sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read up migration: %v", err)
|
||||
}
|
||||
down, err := os.ReadFile(filepath.Join("000009_directory_profiles.down.sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read down migration: %v", err)
|
||||
}
|
||||
upper := strings.ToUpper(string(up))
|
||||
for _, required := range []string{
|
||||
"CREATE TABLE IF NOT EXISTS DIRECTORY_PROFILES",
|
||||
"PRIMARY KEY (TENANT_KEY, STAFF_ID)",
|
||||
"DEPARTMENT_IDS JSONB",
|
||||
"REGION TEXT",
|
||||
"SOURCE TEXT",
|
||||
"ADD COLUMN IF NOT EXISTS DEPARTMENT_SNAPSHOT",
|
||||
"ADD COLUMN IF NOT EXISTS REGION_SNAPSHOT",
|
||||
} {
|
||||
if !strings.Contains(upper, required) {
|
||||
t.Errorf("up migration missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"MOBILE", "EMAIL", "CLIENT_SECRET", "ACCESS_TOKEN", "RAW_PAYLOAD"} {
|
||||
if strings.Contains(upper, forbidden) {
|
||||
t.Errorf("up migration contains forbidden sensitive field %q", forbidden)
|
||||
}
|
||||
}
|
||||
downUpper := strings.ToUpper(string(down))
|
||||
if !strings.Contains(downUpper, "DROP TABLE IF EXISTS DIRECTORY_PROFILES") || !strings.Contains(downUpper, "DROP COLUMN IF EXISTS REGION_SNAPSHOT") {
|
||||
t.Error("down migration must remove directory table and snapshot columns")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 000010_question_table_exports down migration
|
||||
-- Drop in reverse dependency order: first drop export table, then remove column
|
||||
|
||||
DROP TABLE IF EXISTS question_table_exports;
|
||||
|
||||
ALTER TABLE messages
|
||||
DROP COLUMN IF EXISTS trigger_person_snapshot;
|
||||
ALTER TABLE messages
|
||||
DROP COLUMN IF EXISTS actor_union_id_snapshot;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- 000010_question_table_exports: durable outbox for Notable question table async sync
|
||||
-- Add trigger_person_snapshot and actor_union_id_snapshot to messages and create question_table_exports table
|
||||
|
||||
-- 1. Add trigger_person_snapshot and actor_union_id_snapshot columns to messages
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS trigger_person_snapshot TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS actor_union_id_snapshot TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- 2. Create question_table_exports outbox table
|
||||
CREATE TABLE IF NOT EXISTS question_table_exports (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL REFERENCES turns(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'exporting', 'exported', 'export_failed')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempt_at TIMESTAMPTZ,
|
||||
next_attempt_at TIMESTAMPTZ,
|
||||
exported_at TIMESTAMPTZ,
|
||||
failure_code TEXT NOT NULL DEFAULT '',
|
||||
external_record_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_key, channel, turn_id)
|
||||
);
|
||||
|
||||
-- 3. Index for claim query: pending/export_failed/exporting ordered by created_at
|
||||
-- Covers pending, export_failed (when next_attempt_at is NULL or <= NOW()),
|
||||
-- and stale exporting (when last_attempt_at < staleBefore).
|
||||
CREATE INDEX IF NOT EXISTS idx_question_table_exports_claim
|
||||
ON question_table_exports (status, next_attempt_at, created_at, id)
|
||||
WHERE status IN ('pending', 'export_failed')
|
||||
OR (status = 'exporting' AND last_attempt_at IS NOT NULL);
|
||||
|
||||
-- 4. Index for stale exporting recovery
|
||||
CREATE INDEX IF NOT EXISTS idx_question_table_exports_stale
|
||||
ON question_table_exports (status, last_attempt_at)
|
||||
WHERE status = 'exporting';
|
||||
@@ -0,0 +1,161 @@
|
||||
// Package migrations_test contains source-level contract tests for the
|
||||
// 000010_question_table_exports migration. No real database connection is used.
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func read010Migration(t *testing.T, direction string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(".", "000010_question_table_exports."+direction+".sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read 000010 %s migration: %v", direction, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestUp010AddsTriggerPersonSnapshot(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
if !strings.Contains(up, "ALTER TABLE MESSAGES") {
|
||||
t.Fatal("up migration must alter messages")
|
||||
}
|
||||
if !strings.Contains(up, "ADD COLUMN IF NOT EXISTS TRIGGER_PERSON_SNAPSHOT") {
|
||||
t.Error("up migration must add trigger_person_snapshot column")
|
||||
}
|
||||
if !strings.Contains(up, "ADD COLUMN IF NOT EXISTS ACTOR_UNION_ID_SNAPSHOT") {
|
||||
t.Error("up migration must add actor_union_id_snapshot column")
|
||||
}
|
||||
if !strings.Contains(up, "TEXT NOT NULL DEFAULT ''") {
|
||||
t.Error("snapshot columns must be TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010CreatesQuestionTableExports(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
required := []string{
|
||||
"CREATE TABLE IF NOT EXISTS QUESTION_TABLE_EXPORTS",
|
||||
"ID", "TENANT_KEY", "CHANNEL", "TURN_ID",
|
||||
"STATUS", "ATTEMPTS", "LAST_ATTEMPT_AT", "NEXT_ATTEMPT_AT", "EXPORTED_AT",
|
||||
"FAILURE_CODE", "EXTERNAL_RECORD_ID",
|
||||
"CREATED_AT", "UPDATED_AT",
|
||||
}
|
||||
for _, fragment := range required {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010QuestionTableExportsConstraints(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
// FK to turns
|
||||
if !strings.Contains(up, "REFERENCES TURNS(ID)") {
|
||||
t.Error("question_table_exports.turn_id must reference turns(id)")
|
||||
}
|
||||
// UNIQUE (tenant_key, channel, turn_id)
|
||||
if !strings.Contains(up, "UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)") {
|
||||
t.Error("question_table_exports must have UNIQUE (tenant_key, channel, turn_id)")
|
||||
}
|
||||
// status CHECK
|
||||
for _, status := range []string{"PENDING", "EXPORTING", "EXPORTED", "EXPORT_FAILED"} {
|
||||
if !strings.Contains(up, status) {
|
||||
t.Errorf("status CHECK must include %q", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010ClaimIndexes(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
if !strings.Contains(up, "IDX_QUESTION_TABLE_EXPORTS_CLAIM") {
|
||||
t.Error("up migration must create claim index")
|
||||
}
|
||||
if !strings.Contains(up, "WHERE STATUS IN ('PENDING', 'EXPORT_FAILED')") {
|
||||
t.Error("claim index must include pending and export_failed statuses")
|
||||
}
|
||||
if !strings.Contains(up, "IDX_QUESTION_TABLE_EXPORTS_STALE") {
|
||||
t.Error("up migration must create stale exporting index")
|
||||
}
|
||||
if !strings.Contains(up, "WHERE STATUS = 'EXPORTING'") {
|
||||
t.Error("stale index must filter exporting status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010HasNextAttemptAtColumn(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
if !strings.Contains(up, "NEXT_ATTEMPT_AT TIMESTAMPTZ") {
|
||||
t.Error("question_table_exports must have next_attempt_at column for retry backoff")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010HasExternalRecordID(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
if !strings.Contains(up, "EXTERNAL_RECORD_ID TEXT NOT NULL DEFAULT ''") {
|
||||
t.Error("question_table_exports must have external_record_id column (not a1_notation)")
|
||||
}
|
||||
if strings.Contains(up, "A1_NOTATION") {
|
||||
t.Error("question_table_exports must not have stale a1_notation column")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010ClaimIndexCoversNextAttemptAt(t *testing.T) {
|
||||
up := strings.ToUpper(read010Migration(t, "up"))
|
||||
if !strings.Contains(up, "NEXT_ATTEMPT_AT") {
|
||||
t.Error("claim index must cover next_attempt_at for retry backoff filtering")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown010DropsInSafeOrder(t *testing.T) {
|
||||
down := strings.ToUpper(read010Migration(t, "down"))
|
||||
dropExportAt := strings.Index(down, "DROP TABLE IF EXISTS QUESTION_TABLE_EXPORTS")
|
||||
dropTriggerAt := strings.Index(down, "DROP COLUMN IF EXISTS TRIGGER_PERSON_SNAPSHOT")
|
||||
dropUnionAt := strings.Index(down, "DROP COLUMN IF EXISTS ACTOR_UNION_ID_SNAPSHOT")
|
||||
if dropExportAt < 0 || dropTriggerAt < 0 || dropUnionAt < 0 {
|
||||
t.Fatal("down migration must drop export table and both snapshot columns")
|
||||
}
|
||||
if dropExportAt > dropTriggerAt || dropTriggerAt > dropUnionAt {
|
||||
t.Fatal("down migration must drop export table before snapshot columns")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown010DoesNotDropBaselineTables(t *testing.T) {
|
||||
down := strings.ToUpper(read010Migration(t, "down"))
|
||||
for _, table := range []string{"TURNS", "MESSAGES", "SESSION_MAPPINGS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS"} {
|
||||
if strings.Contains(down, "DROP TABLE IF EXISTS "+table) {
|
||||
t.Errorf("down migration must not drop baseline table %q", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp010ExportsNoFormulaColumns(t *testing.T) {
|
||||
up := read010Migration(t, "up")
|
||||
// Check that the CREATE TABLE body does not contain user/AI content columns
|
||||
lines := strings.Split(up, "\n")
|
||||
inTable := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
upper := strings.ToUpper(trimmed)
|
||||
if strings.HasPrefix(upper, "CREATE TABLE IF NOT EXISTS QUESTION_TABLE_EXPORTS") {
|
||||
inTable = true
|
||||
continue
|
||||
}
|
||||
if inTable && trimmed == ");" {
|
||||
inTable = false
|
||||
continue
|
||||
}
|
||||
if !inTable {
|
||||
continue
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"QUESTION", "ANSWER_TEXT", "RAW_PAYLOAD", "SESSIONWEBHOOK", "TOKEN",
|
||||
"RAW_RESPONSE", "RAW_ERROR", "ACCESS_TOKEN", "APP_SECRET",
|
||||
} {
|
||||
if strings.Contains(upper, forbidden) {
|
||||
t.Errorf("question_table_exports column must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
DROP INDEX IF EXISTS idx_support_cases_faq_export_stale;
|
||||
DROP INDEX IF EXISTS idx_support_cases_faq_export_claim;
|
||||
|
||||
ALTER TABLE support_cases
|
||||
DROP COLUMN IF EXISTS faq_record_id,
|
||||
DROP COLUMN IF EXISTS faq_failure_code,
|
||||
DROP COLUMN IF EXISTS faq_exported_at,
|
||||
DROP COLUMN IF EXISTS faq_next_attempt_at,
|
||||
DROP COLUMN IF EXISTS faq_last_attempt_at,
|
||||
DROP COLUMN IF EXISTS faq_export_attempts,
|
||||
DROP COLUMN IF EXISTS faq_export_status;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Durable FAQ export state for uncovered support cases.
|
||||
ALTER TABLE support_cases
|
||||
ADD COLUMN IF NOT EXISTS faq_export_status TEXT NOT NULL DEFAULT 'not_applicable'
|
||||
CHECK (faq_export_status IN ('not_applicable', 'pending', 'exporting', 'exported', 'export_failed')),
|
||||
ADD COLUMN IF NOT EXISTS faq_export_attempts INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (faq_export_attempts >= 0),
|
||||
ADD COLUMN IF NOT EXISTS faq_last_attempt_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS faq_next_attempt_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS faq_exported_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS faq_failure_code TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS faq_record_id TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- Existing cases are intentionally not backfilled. Only cases created after
|
||||
-- this migration enter the FAQ workflow automatically.
|
||||
ALTER TABLE support_cases
|
||||
ALTER COLUMN faq_export_status SET DEFAULT 'pending';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_support_cases_faq_export_claim
|
||||
ON support_cases (faq_export_status, faq_next_attempt_at, created_at, id)
|
||||
WHERE faq_export_status IN ('pending', 'export_failed');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_support_cases_faq_export_stale
|
||||
ON support_cases (faq_export_status, faq_last_attempt_at)
|
||||
WHERE faq_export_status = 'exporting';
|
||||
@@ -0,0 +1,74 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func read011Migration(t *testing.T, direction string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(".", "000011_faq_exports."+direction+".sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read 000011 %s migration: %v", direction, err)
|
||||
}
|
||||
return strings.ToUpper(string(data))
|
||||
}
|
||||
|
||||
func TestUp011AddsDurableFAQExportStateToSupportCases(t *testing.T) {
|
||||
up := read011Migration(t, "up")
|
||||
for _, fragment := range []string{
|
||||
"ALTER TABLE SUPPORT_CASES",
|
||||
"FAQ_EXPORT_STATUS", "FAQ_EXPORT_ATTEMPTS", "FAQ_LAST_ATTEMPT_AT",
|
||||
"FAQ_NEXT_ATTEMPT_AT", "FAQ_EXPORTED_AT", "FAQ_FAILURE_CODE", "FAQ_RECORD_ID",
|
||||
"PENDING", "EXPORTING", "EXPORTED", "EXPORT_FAILED",
|
||||
} {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing %q", fragment)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"FAQ_QUESTION", "FAQ_ANSWER", "RAW_RESPONSE", "ACCESS_TOKEN"} {
|
||||
if strings.Contains(up, forbidden) {
|
||||
t.Errorf("migration must not duplicate business or secret field %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp011DoesNotBackfillHistoricalSupportCases(t *testing.T) {
|
||||
up := read011Migration(t, "up")
|
||||
if !strings.Contains(up, "DEFAULT 'NOT_APPLICABLE'") {
|
||||
t.Fatal("existing support cases must start as not_applicable")
|
||||
}
|
||||
if !strings.Contains(up, "ALTER COLUMN FAQ_EXPORT_STATUS SET DEFAULT 'PENDING'") {
|
||||
t.Fatal("future support cases must default to pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp011AddsBoundedClaimIndexes(t *testing.T) {
|
||||
up := read011Migration(t, "up")
|
||||
for _, fragment := range []string{
|
||||
"IDX_SUPPORT_CASES_FAQ_EXPORT_CLAIM",
|
||||
"FAQ_NEXT_ATTEMPT_AT", "FAQ_LAST_ATTEMPT_AT",
|
||||
"WHERE FAQ_EXPORT_STATUS IN ('PENDING', 'EXPORT_FAILED')",
|
||||
} {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing claim index fragment %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown011OnlyRemovesFAQExportState(t *testing.T) {
|
||||
down := read011Migration(t, "down")
|
||||
for _, column := range []string{
|
||||
"FAQ_EXPORT_STATUS", "FAQ_EXPORT_ATTEMPTS", "FAQ_LAST_ATTEMPT_AT",
|
||||
"FAQ_NEXT_ATTEMPT_AT", "FAQ_EXPORTED_AT", "FAQ_FAILURE_CODE", "FAQ_RECORD_ID",
|
||||
} {
|
||||
if !strings.Contains(down, "DROP COLUMN IF EXISTS "+column) {
|
||||
t.Errorf("down migration missing %s", column)
|
||||
}
|
||||
}
|
||||
if strings.Contains(down, "DROP TABLE") {
|
||||
t.Fatal("down migration must not drop baseline tables")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS config_audit_events; DROP TABLE IF EXISTS system_resource_bindings; DROP TABLE IF EXISTS administrators; DROP TABLE IF EXISTS system_config_revisions;
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE IF NOT EXISTS system_config_revisions (
|
||||
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, kind TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0),
|
||||
state TEXT NOT NULL CHECK(state IN ('draft','validated','active','retired','failed')), public_config JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
secret_ref TEXT NOT NULL DEFAULT '', validation_status TEXT NOT NULL DEFAULT 'pending', validation_code TEXT NOT NULL DEFAULT '',
|
||||
validated_at TIMESTAMPTZ, activated_at TIMESTAMPTZ, created_by_staff_id TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(tenant_key,kind,revision));
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_system_config_active ON system_config_revisions(tenant_key,kind) WHERE state='active';
|
||||
CREATE TABLE IF NOT EXISTS administrators (
|
||||
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, staff_id TEXT NOT NULL, union_id TEXT NOT NULL DEFAULT '', display_name TEXT NOT NULL DEFAULT '',
|
||||
department TEXT NOT NULL DEFAULT '', role TEXT NOT NULL CHECK(role IN ('super_admin','admin')), status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','disabled')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(tenant_key,staff_id));
|
||||
CREATE TABLE IF NOT EXISTS system_resource_bindings (
|
||||
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, kind TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0), state TEXT NOT NULL CHECK(state IN ('draft','validated','active','retired','failed')),
|
||||
public_config JSONB NOT NULL DEFAULT '{}'::JSONB, validation_code TEXT NOT NULL DEFAULT '', validated_at TIMESTAMPTZ, activated_at TIMESTAMPTZ,
|
||||
created_by_staff_id TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(tenant_key,kind,revision));
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_resource_binding_active ON system_resource_bindings(tenant_key,kind) WHERE state='active';
|
||||
CREATE TABLE IF NOT EXISTS config_audit_events (
|
||||
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, actor_staff_id TEXT NOT NULL DEFAULT '', action TEXT NOT NULL, target_kind TEXT NOT NULL,
|
||||
target_revision INTEGER NOT NULL DEFAULT 0, result_code TEXT NOT NULL, metadata JSONB NOT NULL DEFAULT '{}'::JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
|
||||
@@ -0,0 +1,25 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUp012CreatesSettingsWithoutSecrets(t *testing.T) {
|
||||
b, e := os.ReadFile("000012_system_settings.up.sql")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s := strings.ToUpper(string(b))
|
||||
for _, x := range []string{"SYSTEM_CONFIG_REVISIONS", "ADMINISTRATORS", "SYSTEM_RESOURCE_BINDINGS", "CONFIG_AUDIT_EVENTS", "PUBLIC_CONFIG"} {
|
||||
if !strings.Contains(s, x) {
|
||||
t.Errorf("missing %s", x)
|
||||
}
|
||||
}
|
||||
for _, x := range []string{"CLIENT_SECRET JSON", "ACCESS_TOKEN JSON", "API_KEY JSON"} {
|
||||
if strings.Contains(s, x) {
|
||||
t.Errorf("forbidden %s", x)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS admin_bootstrap_challenges;
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS admin_bootstrap_challenges (
|
||||
id TEXT PRIMARY KEY, tenant_key TEXT NOT NULL, code_hash TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts>=0), max_attempts INTEGER NOT NULL DEFAULT 5 CHECK(max_attempts BETWEEN 1 AND 10),
|
||||
candidate_staff_id TEXT NOT NULL DEFAULT '', candidate_union_id TEXT NOT NULL DEFAULT '', candidate_display_name TEXT NOT NULL DEFAULT '', candidate_department TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL CHECK(status IN ('pending','candidate_detected','confirmed','expired','locked')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_admin_bootstrap_pending ON admin_bootstrap_challenges(tenant_key) WHERE status IN ('pending','candidate_detected');
|
||||
@@ -0,0 +1,18 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUp013StoresOnlyBootstrapHash(t *testing.T) {
|
||||
b, e := os.ReadFile("000013_admin_bootstrap.up.sql")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s := strings.ToUpper(string(b))
|
||||
if !strings.Contains(s, "CODE_HASH") || strings.Contains(s, " CODE TEXT") {
|
||||
t.Fatal("bootstrap must store hash only")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS setting_sessions;
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS setting_sessions (
|
||||
tenant_key TEXT NOT NULL, staff_id TEXT NOT NULL, conversation_id TEXT NOT NULL, conversation_type TEXT NOT NULL CHECK(conversation_type IN ('direct','group')),
|
||||
current_step TEXT NOT NULL, pending_action TEXT NOT NULL DEFAULT '', draft_config JSONB NOT NULL DEFAULT '{}'::JSONB, expires_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY(tenant_key,staff_id));
|
||||
CREATE INDEX IF NOT EXISTS idx_setting_sessions_expiry ON setting_sessions(expires_at);
|
||||
@@ -0,0 +1,20 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUp014CreatesExpiringSettingSessions(t *testing.T) {
|
||||
b, e := os.ReadFile("000014_setting_sessions.up.sql")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s := strings.ToUpper(string(b))
|
||||
for _, x := range []string{"SETTING_SESSIONS", "EXPIRES_AT", "DRAFT_CONFIG"} {
|
||||
if !strings.Contains(s, x) {
|
||||
t.Errorf("missing %s", x)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS user_preferences;
|
||||
@@ -0,0 +1,3 @@
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
tenant_key TEXT NOT NULL, channel TEXT NOT NULL, external_actor_id TEXT NOT NULL, language_mode TEXT NOT NULL DEFAULT 'auto' CHECK(language_mode IN ('auto','fixed')),
|
||||
locale TEXT NOT NULL DEFAULT 'auto' CHECK(locale IN ('auto','zh-CN','en','ja','ko','es')), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY(tenant_key,channel,external_actor_id));
|
||||
@@ -0,0 +1,20 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUp015ScopesPreferencesByTenantAndActor(t *testing.T) {
|
||||
b, e := os.ReadFile("000015_user_preferences.up.sql")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s := strings.ToUpper(string(b))
|
||||
for _, x := range []string{"USER_PREFERENCES", "TENANT_KEY", "EXTERNAL_ACTOR_ID", "LANGUAGE_MODE", "LOCALE"} {
|
||||
if !strings.Contains(s, x) {
|
||||
t.Errorf("missing %s", x)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 000016_question_image_urls down migration.
|
||||
ALTER TABLE turns
|
||||
DROP COLUMN IF EXISTS question_image_urls_json;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 000016_question_image_urls: stable published URLs for inbound question images.
|
||||
-- Stores the object-storage HTTPS URLs of images a user sent in a question,
|
||||
-- so the question-table export can render a readable, traceable record without
|
||||
-- persisting download codes or data URIs.
|
||||
ALTER TABLE turns
|
||||
ADD COLUMN IF NOT EXISTS question_image_urls_json JSONB NOT NULL DEFAULT '[]'::JSONB;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 000017_knowledge_searched down migration.
|
||||
ALTER TABLE turns
|
||||
DROP COLUMN IF EXISTS knowledge_searched;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 000017_knowledge_searched: records whether a turn actually triggered a
|
||||
-- knowledge-base search (knowledge_search tool call). Coverage classification
|
||||
-- uses this to distinguish pure visual recognition from a searched-but-missed
|
||||
-- knowledge question.
|
||||
ALTER TABLE turns
|
||||
ADD COLUMN IF NOT EXISTS knowledge_searched BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 000018_coverage_visual_recognition_reason down migration.
|
||||
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'
|
||||
));
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 000018_coverage_visual_recognition_reason: extend the coverage_decisions
|
||||
-- reason_code allowlist with the new image-visual-recognition reason emitted by
|
||||
-- coverage Evaluator v3 (visual recognition is not a knowledge-coverage miss).
|
||||
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'
|
||||
));
|
||||
@@ -56,6 +56,7 @@ No WeKnora source patch is allowed as a workaround.
|
||||
- `internal/qa`: business orchestration and terminal-state ownership.
|
||||
- `internal/session`: external-to-WeKnora session policy.
|
||||
- `internal/store/postgres`: DobotAI business persistence only.
|
||||
- `internal/coverage`: v1 covered/uncovered evaluation, durable support-case dispatch, finite retries, and stable failure codes. Notification failures never change a successful QA result.
|
||||
- `internal/channels/dingtalk`: DingTalk Stream ingress (official SDK), text-only robot callbacks, fail-closed session webhook replies (`https`, `*.dingtalk.com`, no redirects). Wired in `cmd/dobotai`; enabled only when `DINGTALK_CLIENT_ID`, `DINGTALK_CLIENT_SECRET`, and `DOBOTAI_TENANT_KEY` are all set. Group messages require an @-mention; direct chats map to one long-lived session per user, groups to one shared session per group. Images, OpenAPI fallback, and card replies are not implemented yet.
|
||||
- future `channels/*`: platform callbacks and outbound replies.
|
||||
- future `content-sync/*`: DingTalk KB, NAS, and CMS synchronization.
|
||||
|
||||
@@ -3,6 +3,7 @@ module gitea.zheromiku.top/Weknora/dobotai-platform
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
)
|
||||
@@ -15,4 +16,5 @@ require (
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.4.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1 h1:vtiFd0hhPAbyYJjztl0wYUq/PqEGkIlDmVuTIy6zw8Y=
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -26,6 +28,8 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.4.0 h1:Z81tqI5ddIoXDPvVQ7/7CC9TnLM7ubaFG2qXYd5BbYY=
|
||||
golang.org/x/time v0.4.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
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,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -206,6 +207,7 @@ func (s *CardSender) UpdateCard(ctx context.Context, outTrackID, content string,
|
||||
if len(content) > 64*1024 {
|
||||
return errors.New("dingtalk card: content exceeds 64 KiB")
|
||||
}
|
||||
log.Print(formatCardUpdateDiagnostic(content, isFinalize, isError))
|
||||
|
||||
token, err := s.accessToken(ctx)
|
||||
if err != nil {
|
||||
@@ -305,9 +307,25 @@ func (s *CardSender) postCard(ctx context.Context, body map[string]any, token st
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Discard successful response body
|
||||
if len(payload) > 0 {
|
||||
// Body is already bounded by postJSON→readBoundedAPIResponse; just discard
|
||||
return validateDingTalkBusinessResponse(payload)
|
||||
}
|
||||
|
||||
func validateDingTalkBusinessResponse(payload []byte) error {
|
||||
if len(bytes.TrimSpace(payload)) == 0 {
|
||||
return nil
|
||||
}
|
||||
var response struct {
|
||||
Success *bool `json:"success"`
|
||||
ErrCode any `json:"errcode"`
|
||||
}
|
||||
if json.Unmarshal(payload, &response) != nil {
|
||||
return nil
|
||||
}
|
||||
if response.Success != nil && !*response.Success {
|
||||
return errors.New("dingtalk card: API business failure")
|
||||
}
|
||||
if response.ErrCode != nil && fmt.Sprint(response.ErrCode) != "0" {
|
||||
return errors.New("dingtalk card: API business failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -326,8 +344,11 @@ func (s *CardSender) finalizeCard(ctx context.Context, outTrackID, content, toke
|
||||
"isFinalize": isFinalize,
|
||||
"isError": isError,
|
||||
}
|
||||
_, err = s.doJSON(ctx, http.MethodPut, s.streamURL, body, token)
|
||||
return err
|
||||
payload, err := s.doJSON(ctx, http.MethodPut, s.streamURL, body, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDingTalkBusinessResponse(payload)
|
||||
}
|
||||
|
||||
func (s *CardSender) postJSON(ctx context.Context, endpoint string, value any, token string) ([]byte, error) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type cardContentShape struct {
|
||||
bytes int
|
||||
lines int
|
||||
pipeLines int
|
||||
leadingPipeLines int
|
||||
delimiterLines int
|
||||
confirmedTable bool
|
||||
confirmedTablePos int
|
||||
}
|
||||
|
||||
func inspectCardContent(content string) cardContentShape {
|
||||
shape := cardContentShape{
|
||||
bytes: len(content),
|
||||
confirmedTablePos: -1,
|
||||
}
|
||||
if content == "" {
|
||||
return shape
|
||||
}
|
||||
shape.lines = strings.Count(content, "\n") + 1
|
||||
|
||||
lines := splitMarkdownStreamLines(content)
|
||||
for i, line := range lines {
|
||||
if strings.Contains(line.text, "|") {
|
||||
shape.pipeLines++
|
||||
}
|
||||
if isTableHeaderCandidate(line.text) {
|
||||
shape.leadingPipeLines++
|
||||
}
|
||||
if !line.inFence && isCompleteTableDelimiter(line.text) {
|
||||
shape.delimiterLines++
|
||||
}
|
||||
if shape.confirmedTable || i == 0 || line.inFence || lines[i-1].inFence {
|
||||
continue
|
||||
}
|
||||
if isTableHeaderCandidate(lines[i-1].text) && isCompleteTableDelimiter(line.text) {
|
||||
shape.confirmedTable = true
|
||||
shape.confirmedTablePos = lines[i-1].start
|
||||
}
|
||||
}
|
||||
return shape
|
||||
}
|
||||
|
||||
func formatCardUpdateDiagnostic(content string, isFinalize, isError bool) string {
|
||||
shape := inspectCardContent(content)
|
||||
return fmt.Sprintf(
|
||||
"DingTalk card update shape: finalize=%t error=%t bytes=%d lines=%d pipe_lines=%d leading_pipe_lines=%d delimiter_lines=%d confirmed_table=%t table_start=%d",
|
||||
isFinalize,
|
||||
isError,
|
||||
shape.bytes,
|
||||
shape.lines,
|
||||
shape.pipeLines,
|
||||
shape.leadingPipeLines,
|
||||
shape.delimiterLines,
|
||||
shape.confirmedTable,
|
||||
shape.confirmedTablePos,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCardSenderUpdateCardLogsOnlySafeContentShape(t *testing.T) {
|
||||
ts := newCardTestServer()
|
||||
defer ts.Close()
|
||||
sender, err := newCardSenderForTest("client-id", "client-secret", "template-id", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
previousWriter := log.Writer()
|
||||
previousFlags := log.Flags()
|
||||
previousPrefix := log.Prefix()
|
||||
log.SetOutput(&output)
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("")
|
||||
t.Cleanup(func() {
|
||||
log.SetOutput(previousWriter)
|
||||
log.SetFlags(previousFlags)
|
||||
log.SetPrefix(previousPrefix)
|
||||
})
|
||||
|
||||
content := "DO_NOT_LOG_BOUNDARY_TEXT\n\n| 型号 | 负载 |\n| --- | --- |\n| CR5 | 5kg |"
|
||||
if err := sender.UpdateCard(context.Background(), "track-diagnostic", content, false, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := output.String()
|
||||
if !strings.Contains(got, "DingTalk card update shape:") || !strings.Contains(got, "confirmed_table=true") || !strings.Contains(got, "finalize=false") {
|
||||
t.Fatalf("missing safe boundary diagnostic: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "DO_NOT_LOG_BOUNDARY_TEXT") || strings.Contains(got, "CR5") || strings.Contains(got, "track-diagnostic") {
|
||||
t.Fatalf("boundary diagnostic leaked content or card identifier: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCardUpdateDiagnosticReportsStructureWithoutContent(t *testing.T) {
|
||||
content := "DO_NOT_LOG_ANSWER_TEXT\n\n| 型号 | 负载 |\n| --- | --- |\n| CR5 | 5kg |"
|
||||
|
||||
got := formatCardUpdateDiagnostic(content, false, false)
|
||||
wantFields := []string{
|
||||
"DingTalk card update shape:",
|
||||
"finalize=false",
|
||||
"error=false",
|
||||
fmt.Sprintf("bytes=%d", len(content)),
|
||||
"lines=5",
|
||||
"pipe_lines=3",
|
||||
"leading_pipe_lines=3",
|
||||
"delimiter_lines=1",
|
||||
"confirmed_table=true",
|
||||
fmt.Sprintf("table_start=%d", strings.Index(content, "| 型号")),
|
||||
}
|
||||
for _, field := range wantFields {
|
||||
if !strings.Contains(got, field) {
|
||||
t.Fatalf("diagnostic %q missing field %q", got, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "DO_NOT_LOG_ANSWER_TEXT") || strings.Contains(got, "CR5") {
|
||||
t.Fatalf("diagnostic leaked answer content: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCardUpdateDiagnosticDistinguishesFinalPlainText(t *testing.T) {
|
||||
got := formatCardUpdateDiagnostic("普通回答", true, false)
|
||||
wantFields := []string{
|
||||
"finalize=true",
|
||||
"error=false",
|
||||
"lines=1",
|
||||
"pipe_lines=0",
|
||||
"leading_pipe_lines=0",
|
||||
"delimiter_lines=0",
|
||||
"confirmed_table=false",
|
||||
"table_start=-1",
|
||||
}
|
||||
for _, field := range wantFields {
|
||||
if !strings.Contains(got, field) {
|
||||
t.Fatalf("diagnostic %q missing field %q", got, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "普通回答") {
|
||||
t.Fatalf("diagnostic leaked answer content: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ func (p *cardUpdatePump) run(ctx context.Context, ticks <-chan time.Time, stopTi
|
||||
if stopTicks != nil {
|
||||
defer stopTicks()
|
||||
}
|
||||
lastSuccessful := ""
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -52,9 +53,14 @@ func (p *cardUpdatePump) run(ctx context.Context, ticks <-chan time.Time, stopTi
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if latest == lastSuccessful {
|
||||
continue
|
||||
}
|
||||
if err := update(ctx, latest); err != nil {
|
||||
log.Printf("DingTalk async card update failed, continuing")
|
||||
continue
|
||||
}
|
||||
lastSuccessful = latest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ type cardTestServer struct {
|
||||
createBodySize int64 // Content-Length for oversized response
|
||||
streamStatus int
|
||||
streamBodySize int64
|
||||
streamResponse any
|
||||
}
|
||||
|
||||
func newCardTestServer() *cardTestServer {
|
||||
@@ -42,6 +43,7 @@ func newCardTestServer() *cardTestServer {
|
||||
tokenStatus: http.StatusOK,
|
||||
createStatus: http.StatusOK,
|
||||
streamStatus: http.StatusOK,
|
||||
streamResponse: map[string]any{"result": "ok"},
|
||||
tokenResponse: map[string]any{"accessToken": "test-access-token", "expireIn": 3600},
|
||||
createResponse: map[string]any{"result": "ok"},
|
||||
}
|
||||
@@ -91,7 +93,7 @@ func newCardTestServer() *cardTestServer {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"result": "ok"})
|
||||
json.NewEncoder(w).Encode(ts.streamResponse)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -969,6 +971,19 @@ func TestSendFinalCardCreateStreamOnce(t *testing.T) {
|
||||
// Test: UpdateCard with non-final / error flags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestUpdateCardRejectsHTTP200BusinessFailure(t *testing.T) {
|
||||
ts := newCardTestServer()
|
||||
defer ts.Close()
|
||||
ts.streamResponse = map[string]any{"success": false, "code": "InvalidParameter", "message": "sensitive detail"}
|
||||
sender, err := newCardSenderForTest("client-id", "client-secret", "card-template-123", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sender.UpdateCard(context.Background(), "track-1", "answer", true, false); err == nil {
|
||||
t.Fatal("HTTP 200 business failure must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCardNonFinalAndError(t *testing.T) {
|
||||
ts := newCardTestServer()
|
||||
defer ts.Close()
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const displayReferenceMarkerPattern = `\[\[ref[\t ]+id[\t ]*=[\t ]*["']c[0-9]+["'][\t ]*\]\]`
|
||||
|
||||
var (
|
||||
displayReferenceMarkerRE = regexp.MustCompile(displayReferenceMarkerPattern)
|
||||
leadingReferenceGroupColonRE = regexp.MustCompile(`(?m)^[ ]*(?:` + displayReferenceMarkerPattern + `[ ]*)+[::][ ]*`)
|
||||
spacedReferenceGroupRE = regexp.MustCompile(`[ ]+(?:` + displayReferenceMarkerPattern + `[ ]*)+`)
|
||||
spaceBeforeDisplayPunctuation = regexp.MustCompile(`[ ]+([,。;:!?、,.;:!?])`)
|
||||
spaceAfterDisplayColon = regexp.MustCompile(`([::])[\t ]+`)
|
||||
)
|
||||
|
||||
func sanitizeDisplayAnswer(content string) string {
|
||||
if index := strings.LastIndex(content, "[[ref"); index >= 0 && !strings.Contains(content[index:], "]]") {
|
||||
content = content[:index]
|
||||
}
|
||||
if !displayReferenceMarkerRE.MatchString(content) {
|
||||
return content
|
||||
}
|
||||
content = leadingReferenceGroupColonRE.ReplaceAllString(content, "")
|
||||
content = spacedReferenceGroupRE.ReplaceAllString(content, " ")
|
||||
content = displayReferenceMarkerRE.ReplaceAllString(content, "")
|
||||
content = spaceBeforeDisplayPunctuation.ReplaceAllString(content, "$1")
|
||||
content = spaceAfterDisplayColon.ReplaceAllString(content, "$1")
|
||||
return content
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package dingtalk
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSanitizeDisplayAnswerRemovesInternalReferenceMarkers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "adjacent inline markers",
|
||||
input: "结论支持该功能[[ref id=\"c2\"]][[ref id=\"c4\"]]。",
|
||||
want: "结论支持该功能。",
|
||||
},
|
||||
{
|
||||
name: "leading marker group and chinese colon",
|
||||
input: "[[ref id=\"c2\"]][[ref id=\"c4\"]]:建议先检查配置。",
|
||||
want: "建议先检查配置。",
|
||||
},
|
||||
{
|
||||
name: "leading marker group and ascii colon on second line",
|
||||
input: "第一段。\n[[ref id='c12']] : Second paragraph.",
|
||||
want: "第一段。\nSecond paragraph.",
|
||||
},
|
||||
{
|
||||
name: "normal punctuation remains natural",
|
||||
input: "标题:[[ref id=\"c1\"]] 详细说明",
|
||||
want: "标题:详细说明",
|
||||
},
|
||||
{
|
||||
name: "spaces around marker collapse to one",
|
||||
input: "Feature A [[ref id=\"c3\"]] remains supported.",
|
||||
want: "Feature A remains supported.",
|
||||
},
|
||||
{
|
||||
name: "unrelated double bracket syntax is preserved",
|
||||
input: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
|
||||
want: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
|
||||
},
|
||||
{
|
||||
name: "content without references remains byte for byte unchanged",
|
||||
input: "Protocol: value ; keep spacing\n indented",
|
||||
want: "Protocol: value ; keep spacing\n indented",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := sanitizeDisplayAnswer(tt.input); got != tt.want {
|
||||
t.Fatalf("sanitizeDisplayAnswer()=%q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeDisplayAnswerHoldsBackDanglingStreamingReference(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"答案正文[[ref",
|
||||
"答案正文[[ref id=\"c",
|
||||
"答案正文[[ref id=\"c2\"",
|
||||
} {
|
||||
if got := sanitizeDisplayAnswer(input); got != "答案正文" {
|
||||
t.Fatalf("sanitizeDisplayAnswer(%q)=%q", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEffectiveDownloadCodesDeduplicatesAndPreservesOrder(t *testing.T) {
|
||||
cb := BotCallback{
|
||||
DownloadCode: "code-first",
|
||||
DownloadCodes: []string{"code-first", "code-second", " code-third "},
|
||||
}
|
||||
codes := effectiveDownloadCodes(cb)
|
||||
if len(codes) != 3 {
|
||||
t.Fatalf("codes=%v, want 3", codes)
|
||||
}
|
||||
if codes[0] != "code-first" || codes[1] != "code-second" || codes[2] != "code-third" {
|
||||
t.Fatalf("codes=%v, want ordered de-dup", codes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveDownloadCodesSingleOnly(t *testing.T) {
|
||||
cb := BotCallback{DownloadCode: " solo "}
|
||||
codes := effectiveDownloadCodes(cb)
|
||||
if len(codes) != 1 || codes[0] != "solo" {
|
||||
t.Fatalf("codes=%v, want [solo]", codes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveDownloadCodesEmpty(t *testing.T) {
|
||||
if codes := effectiveDownloadCodes(BotCallback{}); len(codes) != 0 {
|
||||
t.Fatalf("codes=%v, want empty", codes)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package dingtalk
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
sdkcard "github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
|
||||
|
||||
@@ -10,16 +11,32 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
ActionFeedbackUp = "dobotai_feedback_up"
|
||||
ActionFeedbackDown = "dobotai_feedback_down"
|
||||
ActionReasonIncorrect = "dobotai_reason_incorrect"
|
||||
ActionReasonNotResolved = "dobotai_reason_not_resolved"
|
||||
ActionReasonIrrelevant = "dobotai_reason_irrelevant"
|
||||
ActionReasonUnclear = "dobotai_reason_unclear"
|
||||
ActionReasonOutdated = "dobotai_reason_outdated"
|
||||
ActionReasonOther = "dobotai_reason_other"
|
||||
ActionNativeFeedback = "sys_action_like_component"
|
||||
ActionFeedbackUp = "dobotai_feedback_up"
|
||||
ActionFeedbackDown = "dobotai_feedback_down"
|
||||
ActionReasonIncorrect = "dobotai_reason_incorrect"
|
||||
ActionReasonNotResolved = "dobotai_reason_not_resolved"
|
||||
ActionReasonIrrelevant = "dobotai_reason_irrelevant"
|
||||
ActionReasonUnclear = "dobotai_reason_unclear"
|
||||
ActionReasonOutdated = "dobotai_reason_outdated"
|
||||
ActionReasonOther = "dobotai_reason_other"
|
||||
ActionReasonContentMismatch = "dobotai_reason_content_mismatch"
|
||||
ActionReasonContentIncomplete = "dobotai_reason_content_incomplete"
|
||||
ActionReasonIllogical = "dobotai_reason_illogical_expression"
|
||||
ActionReasonFactualError = "dobotai_reason_factual_error"
|
||||
ActionReasonSensitiveContent = "dobotai_reason_sensitive_content"
|
||||
ActionReasonAnswerNotWanted = "dobotai_reason_answer_not_wanted"
|
||||
)
|
||||
|
||||
var nativeReasonByTag = map[string]string{
|
||||
"1": feedbackdomain.ReasonContentMismatch,
|
||||
"2": feedbackdomain.ReasonContentIncomplete,
|
||||
"3": feedbackdomain.ReasonIllogical,
|
||||
"4": feedbackdomain.ReasonFactualError,
|
||||
"5": feedbackdomain.ReasonSensitiveContent,
|
||||
"6": feedbackdomain.ReasonAnswerNotWanted,
|
||||
}
|
||||
|
||||
type feedbackService interface {
|
||||
Apply(context.Context, feedbackdomain.Command) (feedbackdomain.Result, error)
|
||||
}
|
||||
@@ -39,7 +56,10 @@ func (h *CardFeedbackHandler) HandleSDK(ctx context.Context, request *sdkcard.Ca
|
||||
if request == nil {
|
||||
return nil, errors.New("dingtalk card feedback: request is nil")
|
||||
}
|
||||
value, reason, recognized := feedbackAction(request.CardActionData.CardPrivateData.ActionIdList)
|
||||
value, reason, recognized := feedbackAction(
|
||||
request.CardActionData.CardPrivateData.ActionIdList,
|
||||
request.CardActionData.CardPrivateData.Params,
|
||||
)
|
||||
if !recognized {
|
||||
return &sdkcard.CardResponse{}, nil
|
||||
}
|
||||
@@ -59,10 +79,6 @@ func (h *CardFeedbackHandler) HandleSDK(ctx context.Context, request *sdkcard.Ca
|
||||
if value == feedbackdomain.ValueUp {
|
||||
message = "感谢反馈"
|
||||
}
|
||||
if value == feedbackdomain.ValueDown && reason == feedbackdomain.ReasonEmpty {
|
||||
showReasons = "true"
|
||||
message = "请选择未解决原因"
|
||||
}
|
||||
return &sdkcard.CardResponse{
|
||||
CardUpdateOptions: &sdkcard.CardUpdateOptions{UpdatePrivateDataByKey: true},
|
||||
UserPrivateData: &sdkcard.CardDataDto{CardParamMap: map[string]string{
|
||||
@@ -74,10 +90,13 @@ func (h *CardFeedbackHandler) HandleSDK(ctx context.Context, request *sdkcard.Ca
|
||||
}, nil
|
||||
}
|
||||
|
||||
func feedbackAction(actions []string) (string, string, bool) {
|
||||
func feedbackAction(actions []string, params map[string]any) (string, string, bool) {
|
||||
value, reason, matches := "", "", 0
|
||||
for _, action := range actions {
|
||||
candidateValue, candidateReason, ok := oneFeedbackAction(action)
|
||||
if action == ActionNativeFeedback {
|
||||
candidateValue, candidateReason, ok = nativeFeedbackAction(params)
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -87,6 +106,50 @@ func feedbackAction(actions []string) (string, string, bool) {
|
||||
return value, reason, matches == 1
|
||||
}
|
||||
|
||||
func nativeFeedbackAction(params map[string]any) (string, string, bool) {
|
||||
key, keyOK := params["variableKey"].(string)
|
||||
if !keyOK || !strings.HasPrefix(key, "node_") {
|
||||
return "", "", false
|
||||
}
|
||||
if nativeVariableKey(key, "_likeOrDislike") {
|
||||
value, ok := params["variableValue"].(string)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
switch value {
|
||||
case "like":
|
||||
return feedbackdomain.ValueUp, feedbackdomain.ReasonEmpty, true
|
||||
case "dislike":
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonEmpty, true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
if nativeVariableKey(key, "_dislikeTags") {
|
||||
values, ok := params["variableValue"].([]any)
|
||||
if !ok || len(values) != 1 {
|
||||
return "", "", false
|
||||
}
|
||||
tag, ok := values[0].(string)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
reason, ok := nativeReasonByTag[tag]
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return feedbackdomain.ValueDown, reason, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func nativeVariableKey(key, suffix string) bool {
|
||||
if !strings.HasPrefix(key, "node_") || !strings.HasSuffix(key, suffix) {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSuffix(strings.TrimPrefix(key, "node_"), suffix) != ""
|
||||
}
|
||||
|
||||
func oneFeedbackAction(action string) (string, string, bool) {
|
||||
switch action {
|
||||
case ActionFeedbackUp:
|
||||
@@ -105,6 +168,18 @@ func oneFeedbackAction(action string) (string, string, bool) {
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonOutdated, true
|
||||
case ActionReasonOther:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonOther, true
|
||||
case ActionReasonContentMismatch:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonContentMismatch, true
|
||||
case ActionReasonContentIncomplete:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonContentIncomplete, true
|
||||
case ActionReasonIllogical:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonIllogical, true
|
||||
case ActionReasonFactualError:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonFactualError, true
|
||||
case ActionReasonSensitiveContent:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonSensitiveContent, true
|
||||
case ActionReasonAnswerNotWanted:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonAnswerNotWanted, true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
@@ -43,6 +43,157 @@ func cardRequest(action string) *sdkcard.CardRequest {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackMapsNativeLike(t *testing.T) {
|
||||
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: feedbackdomain.ValueUp}}
|
||||
handler, err := NewCardFeedbackHandler(service)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := cardRequest(ActionNativeFeedback)
|
||||
request.CardActionData.CardPrivateData.Params = map[string]any{
|
||||
"variableKey": "node_ocmrjfmlsra_likeOrDislike",
|
||||
"variableValue": "like",
|
||||
}
|
||||
response, err := handler.HandleSDK(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if service.calls != 1 || service.command.Value != feedbackdomain.ValueUp || service.command.ReasonCode != feedbackdomain.ReasonEmpty {
|
||||
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
|
||||
}
|
||||
if response == nil || response.UserPrivateData == nil || response.UserPrivateData.CardParamMap["feedbackSelected"] != feedbackdomain.ValueUp {
|
||||
t.Fatalf("response=%+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackMapsNativeDislike(t *testing.T) {
|
||||
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: feedbackdomain.ValueDown}}
|
||||
handler, err := NewCardFeedbackHandler(service)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := cardRequest(ActionNativeFeedback)
|
||||
request.CardActionData.CardPrivateData.Params = map[string]any{
|
||||
"variableKey": "node_ocmrjfmlsra_likeOrDislike",
|
||||
"variableValue": "dislike",
|
||||
}
|
||||
response, err := handler.HandleSDK(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if service.calls != 1 || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != feedbackdomain.ReasonEmpty {
|
||||
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
|
||||
}
|
||||
if response == nil || response.UserPrivateData == nil ||
|
||||
response.UserPrivateData.CardParamMap["showFeedbackReasons"] != "false" ||
|
||||
response.UserPrivateData.CardParamMap["feedbackMessage"] != "感谢反馈,我们会继续改进" {
|
||||
t.Fatalf("response=%+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackMapsNativeReasons(t *testing.T) {
|
||||
tests := []struct {
|
||||
tag string
|
||||
reason string
|
||||
}{
|
||||
{"1", feedbackdomain.ReasonContentMismatch},
|
||||
{"2", feedbackdomain.ReasonContentIncomplete},
|
||||
{"3", feedbackdomain.ReasonIllogical},
|
||||
{"4", feedbackdomain.ReasonFactualError},
|
||||
{"5", feedbackdomain.ReasonSensitiveContent},
|
||||
{"6", feedbackdomain.ReasonAnswerNotWanted},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.tag, func(t *testing.T) {
|
||||
service := &fakeFeedbackService{result: feedbackdomain.Result{Changed: true, Value: feedbackdomain.ValueDown, ReasonCode: tc.reason}}
|
||||
handler, err := NewCardFeedbackHandler(service)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := cardRequest(ActionNativeFeedback)
|
||||
request.CardActionData.CardPrivateData.Params = map[string]any{
|
||||
"variableKey": "node_ocmrjfmlsra_dislikeTags",
|
||||
"variableValue": []any{tc.tag},
|
||||
}
|
||||
response, err := handler.HandleSDK(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if service.calls != 1 || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != tc.reason {
|
||||
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
|
||||
}
|
||||
if response == nil || response.UserPrivateData == nil || response.UserPrivateData.CardParamMap["feedbackReasonSelected"] != tc.reason {
|
||||
t.Fatalf("response=%+v", response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackRejectsMalformedNativeEvents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
actions []string
|
||||
params map[string]any
|
||||
}{
|
||||
{name: "empty node ID", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node__likeOrDislike", "variableValue": "like"}},
|
||||
{name: "unknown suffix", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_other", "variableValue": "like"}},
|
||||
{name: "unknown vote", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_likeOrDislike", "variableValue": "LIKE"}},
|
||||
{name: "wrong vote type", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_likeOrDislike", "variableValue": []any{"like"}}},
|
||||
{name: "empty tags", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{}}},
|
||||
{name: "multiple tags", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{"1", "2"}}},
|
||||
{name: "numeric tag", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{1}}},
|
||||
{name: "unknown tag", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{"7"}}},
|
||||
{name: "ambiguous actions", actions: []string{ActionNativeFeedback, ActionFeedbackUp}, params: map[string]any{"variableKey": "node_x_likeOrDislike", "variableValue": "like"}},
|
||||
{name: "nil params", actions: []string{ActionNativeFeedback}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
service := &fakeFeedbackService{}
|
||||
handler, _ := NewCardFeedbackHandler(service)
|
||||
request := cardRequest("unknown")
|
||||
request.CardActionData.CardPrivateData.ActionIdList = tc.actions
|
||||
request.CardActionData.CardPrivateData.Params = tc.params
|
||||
response, err := handler.HandleSDK(context.Background(), request)
|
||||
if err != nil || response == nil || service.calls != 0 {
|
||||
t.Fatalf("response=%+v error=%v calls=%d", response, err, service.calls)
|
||||
}
|
||||
if response.CardUpdateOptions != nil || response.UserPrivateData != nil {
|
||||
t.Fatalf("safe ACK must not update card: %+v", response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackMapsCustomExactReasons(t *testing.T) {
|
||||
tests := []struct {
|
||||
action string
|
||||
reason string
|
||||
}{
|
||||
{ActionReasonContentMismatch, feedbackdomain.ReasonContentMismatch},
|
||||
{ActionReasonContentIncomplete, feedbackdomain.ReasonContentIncomplete},
|
||||
{ActionReasonIllogical, feedbackdomain.ReasonIllogical},
|
||||
{ActionReasonFactualError, feedbackdomain.ReasonFactualError},
|
||||
{ActionReasonSensitiveContent, feedbackdomain.ReasonSensitiveContent},
|
||||
{ActionReasonAnswerNotWanted, feedbackdomain.ReasonAnswerNotWanted},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.action, func(t *testing.T) {
|
||||
service := &fakeFeedbackService{result: feedbackdomain.Result{Changed: true, Value: feedbackdomain.ValueDown, ReasonCode: tc.reason}}
|
||||
handler, _ := NewCardFeedbackHandler(service)
|
||||
response, err := handler.HandleSDK(context.Background(), cardRequest(tc.action))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if service.calls != 1 || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != tc.reason {
|
||||
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
|
||||
}
|
||||
if response == nil || response.UserPrivateData == nil || response.UserPrivateData.CardParamMap["feedbackReasonSelected"] != tc.reason {
|
||||
t.Fatalf("response=%+v", response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackMapsWhitelistedActions(t *testing.T) {
|
||||
tests := []struct {
|
||||
action string
|
||||
@@ -52,7 +203,7 @@ func TestCardFeedbackMapsWhitelistedActions(t *testing.T) {
|
||||
message string
|
||||
}{
|
||||
{ActionFeedbackUp, feedbackdomain.ValueUp, "", "false", "感谢反馈"},
|
||||
{ActionFeedbackDown, feedbackdomain.ValueDown, "", "true", "请选择未解决原因"},
|
||||
{ActionFeedbackDown, feedbackdomain.ValueDown, "", "false", "感谢反馈,我们会继续改进"},
|
||||
{ActionReasonIncorrect, feedbackdomain.ValueDown, feedbackdomain.ReasonIncorrect, "false", "感谢反馈,我们会继续改进"},
|
||||
{ActionReasonNotResolved, feedbackdomain.ValueDown, feedbackdomain.ReasonNotResolved, "false", "感谢反馈,我们会继续改进"},
|
||||
{ActionReasonIrrelevant, feedbackdomain.ValueDown, feedbackdomain.ReasonIrrelevant, "false", "感谢反馈,我们会继续改进"},
|
||||
@@ -147,6 +298,43 @@ func TestStreamClientRegistersOptionalCardFeedbackRouter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCardRouterHandlesNativeSDKDataFrameEnvelope(t *testing.T) {
|
||||
service := &fakeFeedbackService{result: feedbackdomain.Result{Changed: true, Value: feedbackdomain.ValueDown, ReasonCode: feedbackdomain.ReasonContentMismatch}}
|
||||
feedbackHandler, _ := NewCardFeedbackHandler(service)
|
||||
client, err := NewStreamClient(
|
||||
StreamConfig{ClientID: "id", ClientSecret: "secret", TenantKey: "tenant"},
|
||||
mustTestHandler(t), feedbackHandler,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router, err := client.inner.GetHandler(utils.SubscriptionTypeKCallback, payload.CardInstanceCallbackTopic)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actionData, _ := json.Marshal(map[string]any{"cardPrivateData": map[string]any{
|
||||
"actionIds": []string{ActionNativeFeedback},
|
||||
"params": map[string]any{
|
||||
"variableKey": "node_ocmrjfmlsra_dislikeTags",
|
||||
"variableValue": []string{"1"},
|
||||
},
|
||||
}})
|
||||
outer, _ := json.Marshal(map[string]any{
|
||||
"outTrackId": "track-native-frame", "userId": "user-native-frame", "content": string(actionData),
|
||||
"corpId": "must-not-be-used", "extension": "must-not-be-used",
|
||||
})
|
||||
frameResponse, err := router(context.Background(), &payload.DataFrame{Data: string(outer)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if frameResponse == nil || frameResponse.Code != payload.DataFrameResponseStatusCodeKOK {
|
||||
t.Fatalf("frame response=%+v", frameResponse)
|
||||
}
|
||||
if service.calls != 1 || service.command.ExternalActorID != "user-native-frame" || service.command.OutTrackID != "track-native-frame" || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != feedbackdomain.ReasonContentMismatch {
|
||||
t.Fatalf("service calls=%d command=%+v", service.calls, service.command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCardRouterHandlesRealSDKDataFrameEnvelope(t *testing.T) {
|
||||
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: feedbackdomain.ValueDown, ReasonCode: feedbackdomain.ReasonNotResolved}}
|
||||
feedbackHandler, _ := NewCardFeedbackHandler(service)
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
|
||||
)
|
||||
|
||||
const (
|
||||
groupSendURL = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
|
||||
// maxGroupPayloadBytes caps the serialized JSON body sent to the group
|
||||
// message endpoint.
|
||||
maxGroupPayloadBytes = 64 << 10 // 64 KiB
|
||||
// questionRuneLimit caps the displayed current question.
|
||||
questionRuneLimit = 500
|
||||
// previousQuestionRuneLimit caps each displayed historical question.
|
||||
previousQuestionRuneLimit = 300
|
||||
)
|
||||
|
||||
// SupportGroupSender sends DingTalk group Markdown notifications via the
|
||||
// official groupMessages/send OpenAPI. It is safe by construction: all
|
||||
// config fields are trimmed, nil client installs hardened defaults, and
|
||||
// all error paths are sanitised.
|
||||
type SupportGroupSender struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
openConversation string
|
||||
recordLinks *faqtable.RecordLinkBuilder
|
||||
tokenURL string
|
||||
groupURL string
|
||||
apiClient *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
tokenTill time.Time
|
||||
}
|
||||
|
||||
type SupportGroupSenderOption func(*SupportGroupSender)
|
||||
|
||||
func WithFAQRecordLinkBuilder(builder *faqtable.RecordLinkBuilder) SupportGroupSenderOption {
|
||||
return func(sender *SupportGroupSender) { sender.recordLinks = builder }
|
||||
}
|
||||
|
||||
var _ coverage.Notifier = (*SupportGroupSender)(nil)
|
||||
|
||||
// NewSupportGroupSender creates a SupportGroupSender. All three config values
|
||||
// are required after trimming. A nil http.Client installs a hardened default
|
||||
// (no redirects, Proxy=nil, 15s timeout).
|
||||
func NewSupportGroupSender(clientID, clientSecret, openConversationID string, client *http.Client, opts ...SupportGroupSenderOption) (*SupportGroupSender, error) {
|
||||
return newSupportGroupSender(clientID, clientSecret, openConversationID, client, oauthTokenURL, groupSendURL, false, opts...)
|
||||
}
|
||||
|
||||
// newSupportGroupSenderForTest is used by tests to inject httptest URLs.
|
||||
func newSupportGroupSenderForTest(clientID, clientSecret, openConversationID string, client *http.Client, tokenURL, groupURL string, opts ...SupportGroupSenderOption) (*SupportGroupSender, error) {
|
||||
return newSupportGroupSender(clientID, clientSecret, openConversationID, client, tokenURL, groupURL, true, opts...)
|
||||
}
|
||||
|
||||
func newSupportGroupSender(clientID, clientSecret, openConversationID string, client *http.Client, tokenURL, groupURL string, allowTest bool, opts ...SupportGroupSenderOption) (*SupportGroupSender, error) {
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
clientSecret = strings.TrimSpace(clientSecret)
|
||||
openConversationID = strings.TrimSpace(openConversationID)
|
||||
if clientID == "" {
|
||||
return nil, errors.New("dingtalk group sender: clientID is required")
|
||||
}
|
||||
if clientSecret == "" {
|
||||
return nil, errors.New("dingtalk group sender: clientSecret is required")
|
||||
}
|
||||
if openConversationID == "" {
|
||||
return nil, errors.New("dingtalk group sender: openConversationId is required")
|
||||
}
|
||||
|
||||
base := &http.Client{Timeout: 15 * time.Second}
|
||||
if client != nil {
|
||||
copyClient := *client
|
||||
base = ©Client
|
||||
if base.Timeout <= 0 {
|
||||
base.Timeout = 15 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
apiClient := *base
|
||||
apiClient.CheckRedirect = func(*http.Request, []*http.Request) error {
|
||||
return errors.New("dingtalk API redirect refused")
|
||||
}
|
||||
if !allowTest {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
apiClient.Transport = transport
|
||||
}
|
||||
|
||||
sender := &SupportGroupSender{
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
openConversation: openConversationID,
|
||||
tokenURL: tokenURL,
|
||||
groupURL: groupURL,
|
||||
apiClient: &apiClient,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(sender)
|
||||
}
|
||||
}
|
||||
if allowTest && sender.recordLinks == nil {
|
||||
sender.recordLinks, _ = faqtable.NewRecordLinkBuilder("test-base", "test-sheet", "test-view")
|
||||
}
|
||||
return sender, nil
|
||||
}
|
||||
|
||||
// Send sends a support-case notification to the DingTalk group.
|
||||
func (s *SupportGroupSender) Send(ctx context.Context, notif coverage.SupportNotification) error {
|
||||
if notif.CaseID == "" {
|
||||
return errors.New("dingtalk group sender: CaseID is required")
|
||||
}
|
||||
if s.recordLinks == nil {
|
||||
return errors.New("dingtalk group sender: FAQ record links are required")
|
||||
}
|
||||
recordURL, err := s.recordLinks.Build(notif.FAQRecordID)
|
||||
if err != nil {
|
||||
return errors.New("dingtalk group sender: FAQ record link failed")
|
||||
}
|
||||
|
||||
title, text := s.renderMarkdown(notif)
|
||||
msgParam := map[string]string{
|
||||
"title": title,
|
||||
"text": text,
|
||||
"singleTitle": "去回答",
|
||||
"singleURL": recordURL,
|
||||
}
|
||||
msgParamJSON, err := json.Marshal(msgParam)
|
||||
if err != nil {
|
||||
return errors.New("dingtalk group sender: encode msgParam failed")
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"msgKey": "sampleActionCard",
|
||||
"msgParam": string(msgParamJSON),
|
||||
"openConversationId": s.openConversation,
|
||||
"robotCode": s.clientID,
|
||||
}
|
||||
|
||||
bodyJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return errors.New("dingtalk group sender: encode request body failed")
|
||||
}
|
||||
if len(bodyJSON) > maxGroupPayloadBytes {
|
||||
return fmt.Errorf("dingtalk group sender: payload exceeds %d bytes", maxGroupPayloadBytes)
|
||||
}
|
||||
|
||||
// Get access token
|
||||
token, err := s.accessToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// POST to groupMessages/send
|
||||
respPayload, err := s.doJSON(ctx, http.MethodPost, s.groupURL, body, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check business response
|
||||
var bizResp struct {
|
||||
ProcessQueryKey string `json:"processQueryKey"`
|
||||
ErrCode int `json:"errcode"`
|
||||
}
|
||||
if err := json.Unmarshal(respPayload, &bizResp); err != nil {
|
||||
return errors.New("dingtalk group sender: invalid API response")
|
||||
}
|
||||
if bizResp.ErrCode != 0 {
|
||||
return errors.New("dingtalk group sender: API returned business error")
|
||||
}
|
||||
if strings.TrimSpace(bizResp.ProcessQueryKey) == "" {
|
||||
return errors.New("dingtalk group sender: incomplete API response")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendTest sends a fixed, non-sensitive message used to verify that the
|
||||
// configured robot can reach the candidate support group. It deliberately
|
||||
// does not require an FAQ-table binding.
|
||||
func (s *SupportGroupSender) SendTest(ctx context.Context) error {
|
||||
msgParam, err := json.Marshal(map[string]string{
|
||||
"title": "DobotAI 技术支持群绑定测试",
|
||||
"text": "DobotAI 技术支持群绑定测试成功。",
|
||||
})
|
||||
if err != nil {
|
||||
return errors.New("dingtalk group sender: encode test message failed")
|
||||
}
|
||||
body := map[string]any{
|
||||
"msgKey": "sampleMarkdown",
|
||||
"msgParam": string(msgParam),
|
||||
"openConversationId": s.openConversation,
|
||||
"robotCode": s.clientID,
|
||||
}
|
||||
bodyJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return errors.New("dingtalk group sender: encode test request failed")
|
||||
}
|
||||
if len(bodyJSON) > maxGroupPayloadBytes {
|
||||
return fmt.Errorf("dingtalk group sender: payload exceeds %d bytes", maxGroupPayloadBytes)
|
||||
}
|
||||
token, err := s.accessToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
respPayload, err := s.doJSON(ctx, http.MethodPost, s.groupURL, body, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var response struct {
|
||||
ProcessQueryKey string `json:"processQueryKey"`
|
||||
ErrCode int `json:"errcode"`
|
||||
}
|
||||
if json.Unmarshal(respPayload, &response) != nil {
|
||||
return errors.New("dingtalk group sender: invalid API response")
|
||||
}
|
||||
if response.ErrCode != 0 {
|
||||
return errors.New("dingtalk group sender: API returned business error")
|
||||
}
|
||||
if strings.TrimSpace(response.ProcessQueryKey) == "" {
|
||||
return errors.New("dingtalk group sender: incomplete API response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderMarkdown produces the compact customer-question context shown in the action card.
|
||||
func (s *SupportGroupSender) renderMarkdown(notif coverage.SupportNotification) (title, text string) {
|
||||
title = "知识库未覆盖提醒"
|
||||
question := escapeMarkdown(truncateRunes(notif.Question, questionRuneLimit))
|
||||
sender := escapeMarkdown(notif.SenderName)
|
||||
askedAt := "未知"
|
||||
if !notif.AskedAt.IsZero() {
|
||||
askedAt = notif.AskedAt.In(time.FixedZone("CST", 8*60*60)).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("## 【知识库未覆盖提醒】\n\n")
|
||||
b.WriteString(fmt.Sprintf("**提问人:** %s\n\n", sender))
|
||||
b.WriteString(fmt.Sprintf("**时间:** %s\n\n", askedAt))
|
||||
b.WriteString(fmt.Sprintf("**当前问题:**\n> %s\n\n", question))
|
||||
b.WriteString(fmt.Sprintf("**语言:** %s\n\n", faqtable.DetectLanguage(notif.Question)))
|
||||
b.WriteString("**最近5次历史问题:**\n")
|
||||
if len(notif.PreviousQuestions) == 0 {
|
||||
b.WriteString("暂无历史问题\n")
|
||||
} else {
|
||||
for i, previous := range notif.PreviousQuestions {
|
||||
if i >= 5 {
|
||||
break
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%d. %s\n", i+1, escapeMarkdown(truncateRunes(previous, previousQuestionRuneLimit))))
|
||||
}
|
||||
}
|
||||
return title, b.String()
|
||||
}
|
||||
|
||||
func escapeMarkdown(value string) string {
|
||||
value = strings.Join(strings.Fields(value), " ")
|
||||
replacer := strings.NewReplacer(
|
||||
"\\", "\\\\", "`", "\\`", "*", "\\*", "_", "\\_",
|
||||
"[", "\\[", "]", "\\]", "(", "\\(", ")", "\\)",
|
||||
"#", "\\#", ">", "\\>",
|
||||
)
|
||||
return replacer.Replace(value)
|
||||
}
|
||||
|
||||
func truncateRunes(s string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= limit {
|
||||
return s
|
||||
}
|
||||
return string(runes[:limit]) + "..."
|
||||
}
|
||||
|
||||
func (s *SupportGroupSender) accessToken(ctx context.Context) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.token != "" && time.Now().Before(s.tokenTill) {
|
||||
return s.token, nil
|
||||
}
|
||||
|
||||
payload, err := s.doJSON(ctx, http.MethodPost, s.tokenURL, map[string]string{
|
||||
"appKey": s.clientID,
|
||||
"appSecret": s.clientSecret,
|
||||
}, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpireIn int64 `json:"expireIn"`
|
||||
}
|
||||
if json.Unmarshal(payload, &response) != nil || strings.TrimSpace(response.AccessToken) == "" {
|
||||
return "", errors.New("dingtalk group sender: invalid OAuth response")
|
||||
}
|
||||
|
||||
ttl := time.Duration(response.ExpireIn) * time.Second
|
||||
refreshAfter := ttl - 5*time.Minute
|
||||
if refreshAfter <= 0 {
|
||||
refreshAfter = ttl / 2
|
||||
}
|
||||
if refreshAfter <= 0 {
|
||||
refreshAfter = time.Minute
|
||||
}
|
||||
s.token = response.AccessToken
|
||||
s.tokenTill = time.Now().Add(refreshAfter)
|
||||
return s.token, nil
|
||||
}
|
||||
|
||||
func (s *SupportGroupSender) doJSON(ctx context.Context, method, endpoint string, value any, token string) ([]byte, error) {
|
||||
body, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, errors.New("dingtalk group sender: encode API request failed")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, errors.New("dingtalk group sender: create API request failed")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("x-acs-dingtalk-access-token", token)
|
||||
}
|
||||
|
||||
resp, err := s.apiClient.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
// Propagate redirect errors so callers can detect them.
|
||||
if strings.Contains(err.Error(), "redirect") {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errors.New("dingtalk group sender: API request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// Discard body without reading it — sanitised error
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxAPIResponseBytes))
|
||||
return nil, fmt.Errorf("dingtalk group sender: API HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return readBoundedAPIResponse(resp)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
|
||||
)
|
||||
|
||||
func TestSupportGroupSenderSendsActionCardWithExactFAQRecordLink(t *testing.T) {
|
||||
gs := newGroupTestServer()
|
||||
defer gs.Close()
|
||||
builder, err := faqtable.NewRecordLinkBuilder("jb9-base", "hERWDMS", "qvGDAH2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := newSupportGroupSenderForTest(
|
||||
"appKey", "appSecret", "cid-abc", gs.server.Client(),
|
||||
gs.URL()+"/token", gs.URL()+"/groupMessages/send",
|
||||
WithFAQRecordLinkBuilder(builder),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Send(context.Background(), newTestNotification()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gs.lastSendBody["msgKey"] != "sampleActionCard" {
|
||||
t.Fatalf("msgKey=%v", gs.lastSendBody["msgKey"])
|
||||
}
|
||||
var params map[string]string
|
||||
if err := json.Unmarshal([]byte(gs.lastSendBody["msgParam"].(string)), ¶ms); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if params["singleTitle"] != "去回答" {
|
||||
t.Fatalf("singleTitle=%q", params["singleTitle"])
|
||||
}
|
||||
parsed, err := url.Parse(params["singleURL"])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := parsed.Query()
|
||||
if parsed.Host != "docs.dingtalk.com" || q.Get("rowId") != "record-001" || q.Get("dentryUuid") != "jb9-base" {
|
||||
t.Fatalf("singleURL=%s", params["singleURL"])
|
||||
}
|
||||
text := params["text"]
|
||||
for _, required := range []string{"张三", "2026-07-24 10:16:35", "怎么配置钉钉机器人", "之前的问题一", "之前的问题二", "Chinese"} {
|
||||
if !strings.Contains(text, required) {
|
||||
t.Errorf("text missing %q: %s", required, text)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"回答摘要", "相关结果", "Score", "置信度", "case-001"} {
|
||||
if strings.Contains(text, forbidden) {
|
||||
t.Errorf("text contains forbidden %q: %s", forbidden, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
|
||||
)
|
||||
|
||||
// groupTestServer provides a configurable httptest server for group message
|
||||
// tests, with token and groupMessages/send endpoints.
|
||||
type groupTestServer struct {
|
||||
server *httptest.Server
|
||||
tokenCalls int
|
||||
sendCalls int
|
||||
|
||||
lastTokenBody map[string]string
|
||||
lastSendBody map[string]any
|
||||
lastSendHeader string
|
||||
|
||||
tokenStatus int
|
||||
tokenResponse any
|
||||
|
||||
sendStatus int
|
||||
sendResponse any
|
||||
}
|
||||
|
||||
func newGroupTestServer() *groupTestServer {
|
||||
gs := &groupTestServer{
|
||||
tokenStatus: http.StatusOK,
|
||||
tokenResponse: map[string]any{"accessToken": "test-token", "expireIn": 3600},
|
||||
sendStatus: http.StatusOK,
|
||||
sendResponse: map[string]any{"processQueryKey": "pqk-123"},
|
||||
}
|
||||
gs.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/token":
|
||||
gs.tokenCalls++
|
||||
gs.lastTokenBody = make(map[string]string)
|
||||
json.NewDecoder(r.Body).Decode(&gs.lastTokenBody)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if gs.tokenStatus != http.StatusOK {
|
||||
w.WriteHeader(gs.tokenStatus)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(gs.tokenResponse)
|
||||
case "/groupMessages/send":
|
||||
gs.sendCalls++
|
||||
gs.lastSendHeader = r.Header.Get("x-acs-dingtalk-access-token")
|
||||
gs.lastSendBody = make(map[string]any)
|
||||
json.NewDecoder(r.Body).Decode(&gs.lastSendBody)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if gs.sendStatus != http.StatusOK {
|
||||
w.WriteHeader(gs.sendStatus)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(gs.sendResponse)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
return gs
|
||||
}
|
||||
|
||||
func (gs *groupTestServer) Close() { gs.server.Close() }
|
||||
func (gs *groupTestServer) URL() string { return gs.server.URL }
|
||||
|
||||
func newTestNotification() coverage.SupportNotification {
|
||||
return coverage.SupportNotification{
|
||||
CaseID: "case-001",
|
||||
Question: "怎么配置钉钉机器人?",
|
||||
SenderName: "张三",
|
||||
Channel: "dingtalk",
|
||||
ConversationType: core.ConversationGroup,
|
||||
AskedAt: time.Date(2026, 7, 24, 10, 16, 35, 0, time.FixedZone("CST", 8*60*60)),
|
||||
PreviousQuestions: []string{"之前的问题一", "之前的问题二"},
|
||||
FAQRecordID: "record-001",
|
||||
}
|
||||
}
|
||||
|
||||
// helper to create a sender with test URLs.
|
||||
func newTestGroupSender(t *testing.T, clientID, clientSecret, openConvID string, client *http.Client, tokenURL, groupURL string) *SupportGroupSender {
|
||||
t.Helper()
|
||||
s, err := newSupportGroupSenderForTest(clientID, clientSecret, openConvID, client, tokenURL, groupURL)
|
||||
if err != nil {
|
||||
t.Fatalf("newSupportGroupSenderForTest: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// -------- 1. fail closed on missing config --------
|
||||
|
||||
func TestSupportGroupSenderMissingConfigFailsClosed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, clientID, clientSecret, openConvID string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty clientID", clientSecret: "s", openConvID: "c", wantErr: "clientID"},
|
||||
{name: "empty clientSecret", clientID: "a", openConvID: "c", wantErr: "clientSecret"},
|
||||
{name: "empty openConversationId", clientID: "a", clientSecret: "s", wantErr: "openConversationId"},
|
||||
{name: "all empty", wantErr: "clientID"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := NewSupportGroupSender(tt.clientID, tt.clientSecret, tt.openConvID, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -------- 2. exact endpoint, body, header --------
|
||||
|
||||
func TestSupportGroupSenderExactEndpointBodyHeader(t *testing.T) {
|
||||
gs := newGroupTestServer()
|
||||
defer gs.Close()
|
||||
|
||||
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
|
||||
notif := newTestNotification()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.Send(ctx, notif); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
// Verify token endpoint was called
|
||||
if gs.tokenCalls != 1 {
|
||||
t.Fatalf("tokenCalls = %d, want 1", gs.tokenCalls)
|
||||
}
|
||||
if gs.lastTokenBody["appKey"] != "appKey" || gs.lastTokenBody["appSecret"] != "appSecret" {
|
||||
t.Fatalf("token body = %+v", gs.lastTokenBody)
|
||||
}
|
||||
|
||||
// Verify send endpoint
|
||||
if gs.sendCalls != 1 {
|
||||
t.Fatalf("sendCalls = %d, want 1", gs.sendCalls)
|
||||
}
|
||||
if gs.lastSendHeader != "test-token" {
|
||||
t.Fatalf("send header = %q, want test-token", gs.lastSendHeader)
|
||||
}
|
||||
|
||||
body := gs.lastSendBody
|
||||
if body["msgKey"] != "sampleActionCard" {
|
||||
t.Fatalf("msgKey = %v, want sampleActionCard", body["msgKey"])
|
||||
}
|
||||
if body["openConversationId"] != "cid-abc" {
|
||||
t.Fatalf("openConversationId = %v, want cid-abc", body["openConversationId"])
|
||||
}
|
||||
if body["robotCode"] != "appKey" {
|
||||
t.Fatalf("robotCode = %v, want appKey", body["robotCode"])
|
||||
}
|
||||
|
||||
// msgParam must be a JSON string
|
||||
msgParamRaw, ok := body["msgParam"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("msgParam is not a string, got %T", body["msgParam"])
|
||||
}
|
||||
|
||||
// Parse msgParam as JSON
|
||||
var msgParam map[string]string
|
||||
if err := json.Unmarshal([]byte(msgParamRaw), &msgParam); err != nil {
|
||||
t.Fatalf("msgParam is not valid JSON: %v (raw=%s)", err, msgParamRaw)
|
||||
}
|
||||
if msgParam["title"] == "" {
|
||||
t.Fatal("msgParam.title is empty")
|
||||
}
|
||||
if msgParam["text"] == "" {
|
||||
t.Fatal("msgParam.text is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// -------- 3. msgParam is valid JSON string --------
|
||||
|
||||
func TestSupportGroupSenderMsgParamIsValidJSON(t *testing.T) {
|
||||
gs := newGroupTestServer()
|
||||
defer gs.Close()
|
||||
|
||||
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
|
||||
|
||||
if err := s.Send(context.Background(), newTestNotification()); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
raw := gs.lastSendBody["msgParam"].(string)
|
||||
var mp map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &mp); err != nil {
|
||||
t.Fatalf("msgParam not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
// Verify required keys exist
|
||||
if _, ok := mp["title"]; !ok {
|
||||
t.Fatal("msgParam missing title")
|
||||
}
|
||||
if _, ok := mp["text"]; !ok {
|
||||
t.Fatal("msgParam missing text")
|
||||
}
|
||||
}
|
||||
|
||||
// -------- 4. OAuth token reuse --------
|
||||
|
||||
func TestSupportGroupSenderTokenReuse(t *testing.T) {
|
||||
gs := newGroupTestServer()
|
||||
gs.tokenResponse = map[string]any{"accessToken": "reused-token", "expireIn": 7200}
|
||||
defer gs.Close()
|
||||
|
||||
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
|
||||
|
||||
// Send twice
|
||||
if err := s.Send(context.Background(), newTestNotification()); err != nil {
|
||||
t.Fatalf("first Send: %v", err)
|
||||
}
|
||||
if err := s.Send(context.Background(), newTestNotification()); err != nil {
|
||||
t.Fatalf("second Send: %v", err)
|
||||
}
|
||||
|
||||
// Token should only be fetched once
|
||||
if gs.tokenCalls != 1 {
|
||||
t.Fatalf("tokenCalls = %d, want 1 (token should be reused)", gs.tokenCalls)
|
||||
}
|
||||
if gs.sendCalls != 2 {
|
||||
t.Fatalf("sendCalls = %d, want 2", gs.sendCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- 5. no redirects --------
|
||||
|
||||
func TestSupportGroupSenderRejectsRedirect(t *testing.T) {
|
||||
// Create a target server
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer target.Close()
|
||||
|
||||
// Create a redirector
|
||||
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target.URL+"/groupMessages/send", http.StatusFound)
|
||||
}))
|
||||
defer redirector.Close()
|
||||
|
||||
_, err := newSupportGroupSenderForTest("appKey", "appSecret", "cid-abc", redirector.Client(),
|
||||
redirector.URL+"/token", redirector.URL+"/groupMessages/send")
|
||||
if err != nil {
|
||||
t.Fatalf("newSupportGroupSenderForTest: %v", err)
|
||||
}
|
||||
|
||||
// token endpoint also redirects
|
||||
redirector2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target.URL+"/token", http.StatusFound)
|
||||
}))
|
||||
defer redirector2.Close()
|
||||
|
||||
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", redirector2.Client(),
|
||||
redirector2.URL+"/token", redirector2.URL+"/groupMessages/send")
|
||||
|
||||
err = s.Send(context.Background(), newTestNotification())
|
||||
if err == nil {
|
||||
t.Fatal("expected redirect error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "redirect") {
|
||||
t.Fatalf("error = %v, want containing 'redirect'", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- 6. non-2xx sanitized error --------
|
||||
|
||||
func TestSupportGroupSenderNon2xxSanitized(t *testing.T) {
|
||||
gs := newGroupTestServer()
|
||||
gs.sendStatus = http.StatusInternalServerError
|
||||
defer gs.Close()
|
||||
|
||||
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
|
||||
|
||||
err := s.Send(context.Background(), newTestNotification())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 500, got nil")
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
// Error must NOT contain clientSecret
|
||||
if strings.Contains(errStr, "appSecret") {
|
||||
t.Fatalf("error leaks clientSecret: %s", errStr)
|
||||
}
|
||||
// Error must NOT contain access token
|
||||
if strings.Contains(errStr, "test-token") {
|
||||
t.Fatalf("error leaks access token: %s", errStr)
|
||||
}
|
||||
// Error must NOT contain conversation ID
|
||||
if strings.Contains(errStr, "cid-abc") {
|
||||
t.Fatalf("error leaks openConversationId: %s", errStr)
|
||||
}
|
||||
// Error must NOT contain question
|
||||
if strings.Contains(errStr, "怎么配置") {
|
||||
t.Fatalf("error leaks question: %s", errStr)
|
||||
}
|
||||
// Error must NOT contain answer
|
||||
if strings.Contains(errStr, "钉钉开放平台") {
|
||||
t.Fatalf("error leaks answer: %s", errStr)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- 7. business failure (non-success response) sanitized --------
|
||||
|
||||
func TestSupportGroupSenderBusinessFailureSanitized(t *testing.T) {
|
||||
gs := newGroupTestServer()
|
||||
gs.sendStatus = http.StatusOK
|
||||
// Simulate a business error: DingTalk returns 200 but with error code
|
||||
gs.sendResponse = map[string]any{
|
||||
"errcode": 40035,
|
||||
"errmsg": "不合法的参数",
|
||||
}
|
||||
defer gs.Close()
|
||||
|
||||
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
|
||||
|
||||
err := s.Send(context.Background(), newTestNotification())
|
||||
if err == nil {
|
||||
t.Fatal("expected business error, got nil")
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "appSecret") {
|
||||
t.Fatalf("error leaks clientSecret: %s", errStr)
|
||||
}
|
||||
if strings.Contains(errStr, "test-token") {
|
||||
t.Fatalf("error leaks access token: %s", errStr)
|
||||
}
|
||||
if strings.Contains(errStr, "cid-abc") {
|
||||
t.Fatalf("error leaks openConversationId: %s", errStr)
|
||||
}
|
||||
if strings.Contains(errStr, "怎么配置") {
|
||||
t.Fatalf("error leaks question: %s", errStr)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- 8. long Unicode truncation / 64KiB limit --------
|
||||
|
||||
func TestSupportGroupSenderLongUnicodeTruncation(t *testing.T) {
|
||||
gs := newGroupTestServer()
|
||||
defer gs.Close()
|
||||
|
||||
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
|
||||
|
||||
// Create a notification with a very long current and historical question.
|
||||
longQ := strings.Repeat("你好", 300)
|
||||
longPrevious := strings.Repeat("世界", 600)
|
||||
|
||||
notif := newTestNotification()
|
||||
notif.Question = longQ
|
||||
notif.PreviousQuestions = []string{longPrevious}
|
||||
|
||||
if err := s.Send(context.Background(), notif); err != nil {
|
||||
t.Fatalf("Send with long content: %v", err)
|
||||
}
|
||||
|
||||
// The msgParam text should contain truncated versions
|
||||
raw := gs.lastSendBody["msgParam"].(string)
|
||||
var mp map[string]string
|
||||
if err := json.Unmarshal([]byte(raw), &mp); err != nil {
|
||||
t.Fatalf("msgParam not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
text := mp["text"]
|
||||
// Both current and historical questions are present and rune-truncated.
|
||||
if !strings.Contains(text, "你好") {
|
||||
t.Fatal("text missing current question content")
|
||||
}
|
||||
if !strings.Contains(text, "世界") {
|
||||
t.Fatal("text missing historical question content")
|
||||
}
|
||||
|
||||
// The full body size must be <= 64KiB
|
||||
bodyBytes, _ := json.Marshal(gs.lastSendBody)
|
||||
if len(bodyBytes) > 64*1024 {
|
||||
t.Fatalf("body size = %d, exceeds 64KiB", len(bodyBytes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportGroupSenderPayloadExceeds64KiB(t *testing.T) {
|
||||
gs := newGroupTestServer()
|
||||
defer gs.Close()
|
||||
|
||||
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
|
||||
|
||||
notif := newTestNotification()
|
||||
// Use a very long SenderName to push the rendered markdown + JSON
|
||||
// serialization beyond 64 KiB. Question/Answer are truncated to 500/1000
|
||||
// runes, so they won't contribute much. The SenderName is not truncated.
|
||||
notif.SenderName = strings.Repeat("张", 70*1024) // ~70KB of UTF-8
|
||||
|
||||
err := s.Send(context.Background(), notif)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for oversized payload, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exceeds") && !strings.Contains(err.Error(), "64") && !strings.Contains(err.Error(), "KiB") {
|
||||
t.Fatalf("error = %v, want size-limit error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- 9. error JSON response does not leak --------
|
||||
|
||||
func TestSupportGroupSenderErrorJSONNotLeaked(t *testing.T) {
|
||||
gs := newGroupTestServer()
|
||||
gs.sendStatus = http.StatusBadRequest
|
||||
// Return a large error body
|
||||
gs.sendResponse = nil
|
||||
// Override handler to return raw error JSON
|
||||
gs.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/token" {
|
||||
gs.tokenCalls++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(gs.tokenResponse)
|
||||
return
|
||||
}
|
||||
gs.sendCalls++
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
// Write raw error JSON with sensitive-looking content
|
||||
io.WriteString(w, `{"errcode":40035,"errmsg":"invalid parameter cid-abc","raw":"user appSecret leaked"}`)
|
||||
})
|
||||
defer gs.Close()
|
||||
|
||||
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
|
||||
|
||||
err := s.Send(context.Background(), newTestNotification())
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
// Error must not contain the raw response body
|
||||
if strings.Contains(errStr, "40035") || strings.Contains(errStr, "invalid parameter") {
|
||||
t.Fatalf("error leaks raw API response: %s", errStr)
|
||||
}
|
||||
if strings.Contains(errStr, "appSecret") {
|
||||
t.Fatalf("error leaks clientSecret: %s", errStr)
|
||||
}
|
||||
if strings.Contains(errStr, "cid-abc") {
|
||||
t.Fatalf("error leaks conversation ID: %s", errStr)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- 10. validation: empty CaseID --------
|
||||
|
||||
func TestSupportGroupSenderRejectsEmptyCaseID(t *testing.T) {
|
||||
gs := newGroupTestServer()
|
||||
defer gs.Close()
|
||||
|
||||
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
|
||||
|
||||
notif := newTestNotification()
|
||||
notif.CaseID = ""
|
||||
err := s.Send(context.Background(), notif)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty CaseID, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CaseID") {
|
||||
t.Fatalf("error = %v, want containing 'CaseID'", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- 11. nil client installs hardened defaults --------
|
||||
|
||||
func TestSupportGroupSenderNilClientDefaults(t *testing.T) {
|
||||
gs := newGroupTestServer()
|
||||
defer gs.Close()
|
||||
|
||||
// Use nil client and verify it still works with httptest URLs
|
||||
s, err := newSupportGroupSenderForTest("appKey", "appSecret", "cid-abc", nil, gs.URL()+"/token", gs.URL()+"/groupMessages/send")
|
||||
if err != nil {
|
||||
t.Fatalf("newSupportGroupSenderForTest with nil client: %v", err)
|
||||
}
|
||||
if err := s.Send(context.Background(), newTestNotification()); err != nil {
|
||||
t.Fatalf("Send with nil client: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- 12. Markdown content verification --------
|
||||
|
||||
func TestSupportGroupSenderMarkdownContent(t *testing.T) {
|
||||
gs := newGroupTestServer()
|
||||
defer gs.Close()
|
||||
|
||||
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
|
||||
|
||||
notif := newTestNotification()
|
||||
if err := s.Send(context.Background(), notif); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
raw := gs.lastSendBody["msgParam"].(string)
|
||||
var mp map[string]string
|
||||
if err := json.Unmarshal([]byte(raw), &mp); err != nil {
|
||||
t.Fatalf("msgParam not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
text := mp["text"]
|
||||
title := mp["title"]
|
||||
|
||||
if title != "知识库未覆盖提醒" {
|
||||
t.Fatalf("title = %q", title)
|
||||
}
|
||||
for _, want := range []string{"张三", "2026-07-24 10:16:35", "怎么配置", "之前的问题一", "之前的问题二", "Chinese"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("action-card text missing %q: %s", want, text)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"case-001", "回答摘要", "相关结果", "Score", "置信度"} {
|
||||
if strings.Contains(text, forbidden) {
|
||||
t.Fatalf("action-card text contains forbidden %q: %s", forbidden, text)
|
||||
}
|
||||
}
|
||||
if mp["singleTitle"] != "去回答" || mp["singleURL"] == "" {
|
||||
t.Fatalf("missing FAQ action: %#v", mp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportGroupSenderNoPreviousQuestions(t *testing.T) {
|
||||
gs := newGroupTestServer()
|
||||
defer gs.Close()
|
||||
|
||||
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
|
||||
notif := newTestNotification()
|
||||
notif.PreviousQuestions = nil
|
||||
if err := s.Send(context.Background(), notif); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
if raw := gs.lastSendBody["msgParam"].(string); !strings.Contains(raw, "暂无历史问题") {
|
||||
t.Fatalf("missing empty-history copy: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportGroupSenderSendTestDoesNotRequireFAQBinding(t *testing.T) {
|
||||
gs := newGroupTestServer()
|
||||
defer gs.Close()
|
||||
|
||||
sender, err := newSupportGroupSenderForTest(
|
||||
"appKey", "appSecret", "cid-abc",
|
||||
gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = sender.SendTest(context.Background()); err != nil {
|
||||
t.Fatalf("SendTest: %v", err)
|
||||
}
|
||||
if gs.sendCalls != 1 {
|
||||
t.Fatalf("sendCalls=%d want=1", gs.sendCalls)
|
||||
}
|
||||
if gs.lastSendBody["openConversationId"] != "cid-abc" || gs.lastSendBody["robotCode"] != "appKey" {
|
||||
t.Fatalf("body=%v", gs.lastSendBody)
|
||||
}
|
||||
if gs.lastSendBody["msgKey"] != "sampleMarkdown" {
|
||||
t.Fatalf("msgKey=%v want=sampleMarkdown", gs.lastSendBody["msgKey"])
|
||||
}
|
||||
var params map[string]string
|
||||
if err = json.Unmarshal([]byte(gs.lastSendBody["msgParam"].(string)), ¶ms); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if params["title"] != "DobotAI 技术支持群绑定测试" || !strings.Contains(params["text"], "测试成功") {
|
||||
t.Fatalf("params=%v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportGroupSenderRejectsMalformedOrIncompleteSuccessResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response any
|
||||
}{
|
||||
{name: "malformed JSON", response: json.RawMessage(`{`)},
|
||||
{name: "missing processQueryKey", response: map[string]any{}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gs := newGroupTestServer()
|
||||
gs.sendResponse = tt.response
|
||||
defer gs.Close()
|
||||
|
||||
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
|
||||
err := s.Send(context.Background(), newTestNotification())
|
||||
if err == nil {
|
||||
t.Fatal("expected fail-closed error, got nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), "cid-abc") || strings.Contains(err.Error(), "怎么配置") {
|
||||
t.Fatalf("error leaks notification data: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
|
||||
)
|
||||
|
||||
@@ -32,17 +34,19 @@ type TenantConfig struct {
|
||||
}
|
||||
|
||||
type BotCallback struct {
|
||||
ConversationType string
|
||||
ConversationID string
|
||||
Msgtype string
|
||||
MsgID string
|
||||
SenderNick string
|
||||
SenderStaffID string
|
||||
SenderID string
|
||||
IsInAtList bool
|
||||
SessionWebhook string
|
||||
Text string
|
||||
DownloadCode string
|
||||
ConversationType string
|
||||
ConversationID string
|
||||
ConversationTitle string
|
||||
Msgtype string
|
||||
MsgID string
|
||||
SenderNick string
|
||||
SenderStaffID string
|
||||
SenderID string
|
||||
IsInAtList bool
|
||||
SessionWebhook string
|
||||
Text string
|
||||
DownloadCode string
|
||||
DownloadCodes []string
|
||||
}
|
||||
|
||||
type QAAsker interface {
|
||||
@@ -73,6 +77,45 @@ type ImageDownloader interface {
|
||||
Download(ctx context.Context, downloadCode string) (core.ImageInput, error)
|
||||
}
|
||||
|
||||
// ImagePublisher publishes a downloaded image to object storage and returns a
|
||||
// stable HTTPS URL. Implementation satisfies outboundimage.Publisher.
|
||||
type ImagePublisher interface {
|
||||
Publish(ctx context.Context, data []byte, contentType string) (string, error)
|
||||
}
|
||||
|
||||
type UserDirectory interface {
|
||||
ResolveUser(ctx context.Context, tenantKey, staffID, fallbackName string) (directory.Profile, error)
|
||||
}
|
||||
|
||||
type OutboundContentRewriter interface {
|
||||
Rewrite(ctx context.Context, content string) string
|
||||
}
|
||||
|
||||
type OutboundPartialSanitizer interface {
|
||||
SanitizePartial(content string) string
|
||||
}
|
||||
|
||||
type SettingsMessage struct {
|
||||
TenantKey string
|
||||
Channel string
|
||||
StaffID string
|
||||
UnionID string
|
||||
DisplayName string
|
||||
Department string
|
||||
Text string
|
||||
ConversationType string
|
||||
ConversationID string
|
||||
ConversationTitle string
|
||||
}
|
||||
|
||||
type SettingsRouter interface {
|
||||
Handle(context.Context, SettingsMessage) (handled bool, reply string, err error)
|
||||
}
|
||||
|
||||
type LanguagePreferenceResolver interface {
|
||||
GetPreference(context.Context, string, string, string) (string, error)
|
||||
}
|
||||
|
||||
// FinalCardSender sends an AI card as the final reply. Implementations must
|
||||
// not log err, outTrackID, or content at any level.
|
||||
type FinalCardSender interface {
|
||||
@@ -87,8 +130,13 @@ type StreamingCardSender interface {
|
||||
}
|
||||
|
||||
type HandlerOptions struct {
|
||||
ImageDownloader ImageDownloader
|
||||
CardSender FinalCardSender
|
||||
ImageDownloader ImageDownloader
|
||||
ImagePublisher ImagePublisher
|
||||
CardSender FinalCardSender
|
||||
Directory UserDirectory
|
||||
OutboundRewriter OutboundContentRewriter
|
||||
SettingsRouter SettingsRouter
|
||||
LanguageResolver LanguagePreferenceResolver
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
@@ -96,7 +144,12 @@ type Handler struct {
|
||||
qa QAAsker
|
||||
reply MarkdownSender
|
||||
downloader ImageDownloader
|
||||
imagePublisher ImagePublisher
|
||||
card FinalCardSender
|
||||
directory UserDirectory
|
||||
outboundRewriter OutboundContentRewriter
|
||||
settingsRouter SettingsRouter
|
||||
languageResolver LanguagePreferenceResolver
|
||||
now func() time.Time
|
||||
cardUpdateInterval time.Duration
|
||||
finalDeliveryTimeout time.Duration
|
||||
@@ -136,7 +189,12 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o
|
||||
qa: qa,
|
||||
reply: reply,
|
||||
downloader: opts.ImageDownloader,
|
||||
imagePublisher: opts.ImagePublisher,
|
||||
card: opts.CardSender,
|
||||
directory: opts.Directory,
|
||||
outboundRewriter: opts.OutboundRewriter,
|
||||
settingsRouter: opts.SettingsRouter,
|
||||
languageResolver: opts.LanguageResolver,
|
||||
now: time.Now,
|
||||
cardUpdateInterval: 500 * time.Millisecond,
|
||||
finalDeliveryTimeout: defaultHTTPTimeout,
|
||||
@@ -170,13 +228,60 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
|
||||
return errors.New("dingtalk handler: sender staffId/senderId is required")
|
||||
}
|
||||
|
||||
triggerPerson := strings.TrimSpace(cb.SenderNick)
|
||||
senderName := triggerPerson
|
||||
department := ""
|
||||
region := ""
|
||||
actorUnionID := ""
|
||||
if h.directory != nil {
|
||||
profile, _ := h.directory.ResolveUser(ctx, h.cfg.TenantKey, externalUserID, senderName)
|
||||
if name := strings.TrimSpace(profile.Name); name != "" {
|
||||
senderName = name
|
||||
department = strings.TrimSpace(profile.DepartmentName)
|
||||
region = strings.TrimSpace(profile.Region)
|
||||
}
|
||||
actorUnionID = strings.TrimSpace(profile.UnionID)
|
||||
}
|
||||
// Trigger person snapshot falls back to official name when callback nick is blank.
|
||||
if triggerPerson == "" {
|
||||
triggerPerson = senderName
|
||||
}
|
||||
|
||||
if h.settingsRouter != nil && strings.EqualFold(strings.TrimSpace(cb.Msgtype), msgTypeText) {
|
||||
handled, settingsReply, err := h.settingsRouter.Handle(ctx, SettingsMessage{
|
||||
TenantKey: h.cfg.TenantKey, Channel: h.cfg.Channel, StaffID: externalUserID,
|
||||
UnionID: actorUnionID, DisplayName: senderName, Department: department, Text: strings.TrimSpace(cb.Text),
|
||||
ConversationType: mapDingTalkConversationType(cb.ConversationType),
|
||||
ConversationID: strings.TrimSpace(cb.ConversationID), ConversationTitle: strings.TrimSpace(cb.ConversationTitle),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dingtalk settings: %w", err)
|
||||
}
|
||||
if handled {
|
||||
if strings.TrimSpace(settingsReply) == "" {
|
||||
return nil
|
||||
}
|
||||
return h.sendReply(ctx, webhookURL, settingsReply)
|
||||
}
|
||||
}
|
||||
|
||||
req := core.QARequest{
|
||||
TenantKey: h.cfg.TenantKey,
|
||||
Channel: h.cfg.Channel,
|
||||
ExternalUserID: externalUserID,
|
||||
ExternalMessageID: strings.TrimSpace(cb.MsgID),
|
||||
SenderName: cb.SenderNick,
|
||||
MessageType: strings.ToLower(strings.TrimSpace(cb.Msgtype)),
|
||||
TenantKey: h.cfg.TenantKey,
|
||||
Channel: h.cfg.Channel,
|
||||
ExternalUserID: externalUserID,
|
||||
ExternalMessageID: strings.TrimSpace(cb.MsgID),
|
||||
SenderName: senderName,
|
||||
TriggerPersonSnapshot: triggerPerson,
|
||||
ActorUnionIDSnapshot: actorUnionID,
|
||||
Department: department,
|
||||
Region: region,
|
||||
MessageType: strings.ToLower(strings.TrimSpace(cb.Msgtype)),
|
||||
ConversationTitle: strings.TrimSpace(cb.ConversationTitle),
|
||||
}
|
||||
if h.languageResolver != nil {
|
||||
if locale, err := h.languageResolver.GetPreference(ctx, h.cfg.TenantKey, h.cfg.Channel, externalUserID); err == nil {
|
||||
req.ResponseLocale = strings.TrimSpace(locale)
|
||||
}
|
||||
}
|
||||
switch cb.ConversationType {
|
||||
case conversationTypeGroup:
|
||||
@@ -187,6 +292,7 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
|
||||
req.ExternalChatID = strings.TrimSpace(cb.ConversationID)
|
||||
default:
|
||||
req.ConversationType = core.ConversationDirect
|
||||
req.ConversationTitle = ""
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(cb.Msgtype)) {
|
||||
@@ -196,7 +302,8 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
|
||||
return errors.New("dingtalk handler: text message content is empty")
|
||||
}
|
||||
case msgTypePicture:
|
||||
if strings.TrimSpace(cb.DownloadCode) == "" {
|
||||
codes := effectiveDownloadCodes(cb)
|
||||
if len(codes) == 0 {
|
||||
log.Printf("DingTalk image preparation failed: stage=missing_download_code")
|
||||
return h.sendReply(ctx, webhookURL, imageErrorReply)
|
||||
}
|
||||
@@ -204,16 +311,25 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
|
||||
log.Printf("DingTalk image preparation failed: stage=downloader_unavailable")
|
||||
return h.sendReply(ctx, webhookURL, imageErrorReply)
|
||||
}
|
||||
image, err := h.downloader.Download(ctx, strings.TrimSpace(cb.DownloadCode))
|
||||
if err != nil {
|
||||
log.Printf("DingTalk image preparation failed: stage=%s", imageFailureStage(err))
|
||||
return h.sendReply(ctx, webhookURL, imageErrorReply)
|
||||
images := make([]core.ImageInput, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
image, err := h.downloader.Download(ctx, code)
|
||||
if err != nil {
|
||||
log.Printf("DingTalk image preparation failed: stage=%s", imageFailureStage(err))
|
||||
return h.sendReply(ctx, webhookURL, imageErrorReply)
|
||||
}
|
||||
if h.imagePublisher != nil {
|
||||
if url := h.publishQuestionImage(ctx, image); url != "" {
|
||||
image.PublishedURL = url
|
||||
}
|
||||
}
|
||||
images = append(images, image)
|
||||
}
|
||||
req.Query = strings.TrimSpace(cb.Text)
|
||||
if req.Query == "" {
|
||||
req.Query = imageQuery
|
||||
}
|
||||
req.Images = []core.ImageInput{image}
|
||||
req.Images = images
|
||||
default:
|
||||
return h.sendReply(ctx, webhookURL, unsupportedMessageReply)
|
||||
}
|
||||
@@ -229,6 +345,13 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
|
||||
return h.handleNonStreaming(ctx, webhookURL, req)
|
||||
}
|
||||
|
||||
func mapDingTalkConversationType(value string) string {
|
||||
if value == conversationTypeGroup {
|
||||
return "group"
|
||||
}
|
||||
return "direct"
|
||||
}
|
||||
|
||||
func (h *Handler) eventStreamingCapable() (StreamingCardSender, EventQAAsker) {
|
||||
sc, ok := h.card.(StreamingCardSender)
|
||||
if !ok {
|
||||
@@ -256,6 +379,7 @@ func (h *Handler) streamingCapable() (StreamingCardSender, StreamingQAAsker) {
|
||||
func (h *Handler) handleNonStreaming(ctx context.Context, webhookURL string, req core.QARequest) error {
|
||||
result, err := h.qa.Ask(ctx, req)
|
||||
if err != nil {
|
||||
logQAFailure("non_streaming", err)
|
||||
if replyErr := h.sendReply(ctx, webhookURL, qaErrorReply); replyErr != nil {
|
||||
return replyErr
|
||||
}
|
||||
@@ -264,13 +388,14 @@ func (h *Handler) handleNonStreaming(ctx context.Context, webhookURL string, req
|
||||
if result.Replayed && result.DeliveryStatus == core.DeliverySent {
|
||||
return nil
|
||||
}
|
||||
content := h.rewriteOutboundContent(ctx, result.Answer)
|
||||
|
||||
if h.card != nil {
|
||||
cardReq := CardRequest{
|
||||
ConversationType: req.ConversationType,
|
||||
ExternalUserID: req.ExternalUserID,
|
||||
ExternalChatID: req.ExternalChatID,
|
||||
Content: result.Answer,
|
||||
Content: content,
|
||||
}
|
||||
if outTrackID, cardErr := h.card.SendFinalCard(ctx, cardReq); cardErr == nil {
|
||||
h.recordDelivery(ctx, result, core.DeliverySent, core.DeliveryMethodCard, outTrackID, "")
|
||||
@@ -278,7 +403,7 @@ func (h *Handler) handleNonStreaming(ctx context.Context, webhookURL string, req
|
||||
}
|
||||
log.Printf("DingTalk card delivery failed, falling back to markdown")
|
||||
}
|
||||
if err := h.sendReply(ctx, webhookURL, result.Answer); err != nil {
|
||||
if err := h.sendReply(ctx, webhookURL, content); err != nil {
|
||||
h.recordDelivery(ctx, result, core.DeliveryFailed, "", "", core.DeliveryFailureChannelDelivery)
|
||||
return err
|
||||
}
|
||||
@@ -299,6 +424,7 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
|
||||
// Fallback: Ask → Markdown only (no SendFinalCard, no AskStream)
|
||||
result, askErr := h.qa.Ask(ctx, req)
|
||||
if askErr != nil {
|
||||
logQAFailure("streaming_fallback", askErr)
|
||||
if replyErr := h.sendReply(ctx, webhookURL, qaErrorReply); replyErr != nil {
|
||||
return replyErr
|
||||
}
|
||||
@@ -307,7 +433,8 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
|
||||
if result.Replayed && result.DeliveryStatus == core.DeliverySent {
|
||||
return nil
|
||||
}
|
||||
if replyErr := h.sendReply(ctx, webhookURL, result.Answer); replyErr != nil {
|
||||
content := h.rewriteOutboundContent(ctx, result.Answer)
|
||||
if replyErr := h.sendReply(ctx, webhookURL, content); replyErr != nil {
|
||||
h.recordDelivery(ctx, result, core.DeliveryFailed, "", "", core.DeliveryFailureChannelDelivery)
|
||||
return replyErr
|
||||
}
|
||||
@@ -317,24 +444,32 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
|
||||
|
||||
// Streaming observer with the configured update throttle (500ms by default).
|
||||
var lastUpdate time.Time
|
||||
var lastPartialContent string
|
||||
observer := func(fullContent string) {
|
||||
if strings.TrimSpace(fullContent) == "" {
|
||||
return
|
||||
}
|
||||
now := h.now()
|
||||
if lastUpdate.IsZero() || now.Sub(lastUpdate) >= h.cardUpdateInterval {
|
||||
partialContent := h.sanitizeOutboundPartial(fullContent)
|
||||
if strings.TrimSpace(partialContent) == "" || partialContent == lastPartialContent {
|
||||
return
|
||||
}
|
||||
// Advance the throttle window before I/O so a failing DingTalk API
|
||||
// cannot be hammered once per model chunk. The final update below is
|
||||
// always attempted regardless of this timestamp.
|
||||
lastUpdate = now
|
||||
if updateErr := streamingCard.UpdateCard(ctx, outTrackID, fullContent, false, false); updateErr != nil {
|
||||
if updateErr := streamingCard.UpdateCard(ctx, outTrackID, partialContent, false, false); updateErr != nil {
|
||||
log.Printf("DingTalk streaming card update failed, continuing")
|
||||
return
|
||||
}
|
||||
lastPartialContent = partialContent
|
||||
}
|
||||
}
|
||||
|
||||
result, err := streamingQA.AskStream(ctx, req, observer)
|
||||
if err != nil || strings.TrimSpace(result.Answer) == "" {
|
||||
logQAFailure("streaming", errOrEmptyAnswer(err, result.Answer))
|
||||
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, core.QAResult{}, qaErrorReply, true)
|
||||
}
|
||||
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, result, result.Answer, false)
|
||||
@@ -352,12 +487,14 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
|
||||
log.Printf("DingTalk event card start failed, falling back to markdown")
|
||||
result, askErr := h.qa.Ask(ctx, req)
|
||||
if askErr != nil {
|
||||
logQAFailure("event_fallback", askErr)
|
||||
return h.sendReply(ctx, webhookURL, qaErrorReply)
|
||||
}
|
||||
if result.Replayed && result.DeliveryStatus == core.DeliverySent {
|
||||
return nil
|
||||
}
|
||||
if replyErr := h.sendReply(ctx, webhookURL, result.Answer); replyErr != nil {
|
||||
content := h.rewriteOutboundContent(ctx, result.Answer)
|
||||
if replyErr := h.sendReply(ctx, webhookURL, content); replyErr != nil {
|
||||
h.recordDelivery(ctx, result, core.DeliveryFailed, "", "", core.DeliveryFailureChannelDelivery)
|
||||
return replyErr
|
||||
}
|
||||
@@ -372,7 +509,9 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
|
||||
result, err := eventQA.AskEvents(ctx, req, func(event weknora.StreamEvent) {
|
||||
if event.ResponseType == "answer" && strings.TrimSpace(event.FullAnswer) != "" {
|
||||
answerStarted = true
|
||||
pump.Offer(event.FullAnswer)
|
||||
if partialContent := h.sanitizeOutboundPartial(event.FullAnswer); strings.TrimSpace(partialContent) != "" {
|
||||
pump.Offer(partialContent)
|
||||
}
|
||||
return
|
||||
}
|
||||
if answerStarted {
|
||||
@@ -387,6 +526,7 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
|
||||
pump.Stop()
|
||||
|
||||
if err != nil || strings.TrimSpace(result.Answer) == "" {
|
||||
logQAFailure("event_streaming", errOrEmptyAnswer(err, result.Answer))
|
||||
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, core.QAResult{}, qaErrorReply, true)
|
||||
}
|
||||
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, result, result.Answer, false)
|
||||
@@ -398,6 +538,10 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
|
||||
timeout = defaultHTTPTimeout
|
||||
}
|
||||
|
||||
if !isError {
|
||||
content = h.rewriteOutboundContent(ctx, content)
|
||||
}
|
||||
|
||||
// Final card convergence must still be attempted when the QA context was
|
||||
// canceled or timed out; otherwise the card can remain "processing".
|
||||
finalCtx, cancelFinal := context.WithTimeout(context.WithoutCancel(ctx), timeout)
|
||||
@@ -426,6 +570,27 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) string {
|
||||
if h.outboundRewriter == nil {
|
||||
return sanitizeDisplayAnswer(content)
|
||||
}
|
||||
timeout := h.finalDeliveryTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultHTTPTimeout
|
||||
}
|
||||
rewriteCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
|
||||
defer cancel()
|
||||
return sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content))
|
||||
}
|
||||
|
||||
func (h *Handler) sanitizeOutboundPartial(content string) string {
|
||||
sanitizer, ok := h.outboundRewriter.(OutboundPartialSanitizer)
|
||||
if ok {
|
||||
content = sanitizer.SanitizePartial(content)
|
||||
}
|
||||
return stableMarkdownPrefix(sanitizeDisplayAnswer(content))
|
||||
}
|
||||
|
||||
func (h *Handler) recordDelivery(ctx context.Context, result core.QAResult, status, method, providerMessageID, failureCode string) {
|
||||
if strings.TrimSpace(result.TurnID) == "" || strings.TrimSpace(result.AnswerMessageID) == "" {
|
||||
return
|
||||
@@ -477,6 +642,50 @@ func renderProgressEvent(event weknora.StreamEvent) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func errOrEmptyAnswer(err error, answer string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(answer) == "" {
|
||||
return weknora.ErrEmptyAnswer
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func logQAFailure(mode string, err error) {
|
||||
log.Printf("DingTalk QA failed: mode=%s stage=%s", mode, qaFailureStage(err))
|
||||
}
|
||||
|
||||
func qaFailureStage(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return "unknown"
|
||||
case errors.Is(err, context.Canceled):
|
||||
return "context_canceled"
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return "context_deadline"
|
||||
case errors.Is(err, core.ErrTurnInProgress):
|
||||
return "duplicate_or_in_progress"
|
||||
case errors.Is(err, weknora.ErrEmptyAnswer):
|
||||
return "empty_answer"
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(message, "begin turn"):
|
||||
return "begin_turn"
|
||||
case strings.Contains(message, "agent") && strings.Contains(message, "binding"):
|
||||
return "agent_binding"
|
||||
case strings.Contains(message, "session"):
|
||||
return "session"
|
||||
case strings.Contains(message, "weknora agent chat"):
|
||||
return "agent_chat"
|
||||
case strings.Contains(message, "required") || strings.Contains(message, "unsupported"):
|
||||
return "validation"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func imageFailureStage(err error) string {
|
||||
message := strings.ToLower(err.Error())
|
||||
switch {
|
||||
@@ -504,3 +713,57 @@ func (h *Handler) sendReply(ctx context.Context, webhookURL, text string) error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishQuestionImage best-effort publishes a downloaded image's bytes to
|
||||
// object storage for traceability. It returns the stable HTTPS URL, or an
|
||||
// empty string when publishing is unavailable or fails, so that an image
|
||||
// question is never blocked by storage.
|
||||
func (h *Handler) publishQuestionImage(ctx context.Context, image core.ImageInput) string {
|
||||
if h.imagePublisher == nil {
|
||||
return ""
|
||||
}
|
||||
data, err := decodeImageDataURI(image.DataURI)
|
||||
if err != nil || len(data) == 0 {
|
||||
log.Printf("DingTalk question image publish failed: stage=decode")
|
||||
return ""
|
||||
}
|
||||
url, err := h.imagePublisher.Publish(ctx, data, image.MIMEType)
|
||||
if err != nil || strings.TrimSpace(url) == "" {
|
||||
log.Printf("DingTalk question image publish failed: stage=publish")
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(url)
|
||||
}
|
||||
|
||||
// decodeImageDataURI strips the "data:<mime>;base64," prefix and decodes the
|
||||
// payload into raw bytes. It is used only to publish inbound images and never
|
||||
// writes the data URI to storage.
|
||||
func decodeImageDataURI(dataURI string) ([]byte, error) {
|
||||
const prefix = "base64,"
|
||||
index := strings.Index(dataURI, prefix)
|
||||
if index < 0 {
|
||||
return nil, errors.New("dingtalk image: data URI missing base64 payload")
|
||||
}
|
||||
return base64.StdEncoding.DecodeString(dataURI[index+len(prefix):])
|
||||
}
|
||||
|
||||
// effectiveDownloadCodes returns the ordered, de-duplicated list of image
|
||||
// download codes for a picture callback. DownloadCode equals the first code
|
||||
// and DownloadCodes holds every code (richText with several pictures), so the
|
||||
// single value is used to canonicalize the order without duplication.
|
||||
func effectiveDownloadCodes(cb BotCallback) []string {
|
||||
var codes []string
|
||||
seen := make(map[string]struct{})
|
||||
for _, code := range append([]string{cb.DownloadCode}, cb.DownloadCodes...) {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[code]; ok {
|
||||
continue
|
||||
}
|
||||
seen[code] = struct{}{}
|
||||
codes = append(codes, code)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ package dingtalk
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
|
||||
)
|
||||
|
||||
@@ -31,6 +33,17 @@ func (f *fakeQA) RecordDelivery(_ context.Context, update core.DeliveryUpdate) e
|
||||
return f.deliveryErr
|
||||
}
|
||||
|
||||
type fakeDirectory struct {
|
||||
profile directory.Profile
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeDirectory) ResolveUser(context.Context, string, string, string) (directory.Profile, error) {
|
||||
f.calls++
|
||||
return f.profile, f.err
|
||||
}
|
||||
|
||||
type fakeReply struct {
|
||||
called bool
|
||||
lastURL string
|
||||
@@ -192,6 +205,44 @@ func TestHandleDirectTextCallsQAAndRepliesAnswer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDirectUsesDirectoryProfile(t *testing.T) {
|
||||
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
|
||||
reply := &fakeReply{}
|
||||
profiles := &fakeDirectory{profile: directory.Profile{Name: "张三", DepartmentName: "技术支持部", Region: "APAC"}}
|
||||
h, err := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{Directory: profiles})
|
||||
if err != nil {
|
||||
t.Fatalf("NewHandlerWithOptions: %v", err)
|
||||
}
|
||||
data := directTextCallback()
|
||||
data.SenderNick = "钉钉昵称"
|
||||
if err := h.Handle(context.Background(), data); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if profiles.calls != 1 || qa.lastReq.SenderName != "张三" || qa.lastReq.Department != "技术支持部" || qa.lastReq.Region != "APAC" {
|
||||
t.Fatalf("calls=%d request=%+v", profiles.calls, qa.lastReq)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleDirectoryFailureFallsBackToSenderNick validates that when directory
|
||||
// resolution fails, SenderName falls back to the callback SenderNick.
|
||||
func TestHandleDirectoryFailureFallsBackToSenderNick(t *testing.T) {
|
||||
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
|
||||
reply := &fakeReply{}
|
||||
profiles := &fakeDirectory{err: errors.New("directory unavailable")}
|
||||
h, err := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{Directory: profiles})
|
||||
if err != nil {
|
||||
t.Fatalf("NewHandlerWithOptions: %v", err)
|
||||
}
|
||||
data := directTextCallback()
|
||||
data.SenderNick = " 回调昵称 "
|
||||
if err := h.Handle(context.Background(), data); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if qa.lastReq.SenderName != "回调昵称" || qa.lastReq.Department != "" || qa.lastReq.Region != "" {
|
||||
t.Fatalf("request=%+v", qa.lastReq)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDirectTextFallsBackToSenderID(t *testing.T) {
|
||||
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
|
||||
reply := &fakeReply{}
|
||||
@@ -676,6 +727,46 @@ func TestHandleCardSuccessDirectFieldsCorrectNoMarkdown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNonStreamingRewritesFinalCardAndMarkdownFallback(t *testing.T) {
|
||||
resource := "resource://AbCdEfGhIjKlMnOpQrStUv"
|
||||
signed := "https://img.example/signed.jpg"
|
||||
answer := "说明\n"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cardErr error
|
||||
wantReply bool
|
||||
}{
|
||||
{name: "card"},
|
||||
{name: "markdown fallback", cardErr: errors.New("card failed"), wantReply: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
qa := &fakeQA{result: core.QAResult{Answer: answer}}
|
||||
reply := &fakeReply{}
|
||||
card := &fakeCard{returnID: "track", err: tc.cardErr}
|
||||
h := newTestHandlerWithCard(t, qa, reply, card)
|
||||
rewriter := &fakeOutboundRewriter{from: resource, to: signed}
|
||||
h.outboundRewriter = rewriter
|
||||
|
||||
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !card.called || reply.called != tc.wantReply {
|
||||
t.Fatalf("card=%t reply=%t", card.called, reply.called)
|
||||
}
|
||||
content := card.lastReq.Content
|
||||
if tc.wantReply {
|
||||
content = reply.lastText
|
||||
}
|
||||
if strings.Contains(content, resource) || !strings.Contains(content, signed) {
|
||||
t.Fatalf("outbound content=%q", content)
|
||||
}
|
||||
if rewriter.calls != 1 {
|
||||
t.Fatalf("rewrite calls=%d, want 1", rewriter.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCardSuccessGroupChatIDCorrect(t *testing.T) {
|
||||
qa := &fakeQA{result: core.QAResult{Answer: "group card answer"}}
|
||||
reply := &fakeReply{}
|
||||
@@ -890,6 +981,23 @@ type cardUpdate struct {
|
||||
isError bool
|
||||
}
|
||||
|
||||
type fakeOutboundRewriter struct {
|
||||
calls int
|
||||
partialCalls int
|
||||
from string
|
||||
to string
|
||||
}
|
||||
|
||||
func (f *fakeOutboundRewriter) Rewrite(_ context.Context, content string) string {
|
||||
f.calls++
|
||||
return strings.ReplaceAll(content, f.from, f.to)
|
||||
}
|
||||
|
||||
func (f *fakeOutboundRewriter) SanitizePartial(content string) string {
|
||||
f.partialCalls++
|
||||
return strings.ReplaceAll(content, f.from, "")
|
||||
}
|
||||
|
||||
func (f *fakeStreamingCard) SendFinalCard(_ context.Context, req CardRequest) (string, error) {
|
||||
f.finalCalled = true
|
||||
f.finalReq = req
|
||||
@@ -1073,11 +1181,14 @@ func TestHandleStreamingThrottleClockAdvances(t *testing.T) {
|
||||
|
||||
func TestHandleStreamingStartFailsFallbackAskMarkdown(t *testing.T) {
|
||||
card := &fakeStreamingCard{startErr: errors.New("card API unavailable")}
|
||||
resource := "resource://AbCdEfGhIjKlMnOpQrStUv"
|
||||
qa := &fakeStreamingQA{
|
||||
result: core.QAResult{Answer: "**fallback markdown**"},
|
||||
result: core.QAResult{Answer: "**fallback markdown**\n"},
|
||||
}
|
||||
reply := &fakeReply{}
|
||||
h := newTestHandlerWithStreaming(t, qa, reply, card)
|
||||
rewriter := &fakeOutboundRewriter{from: resource, to: "https://img.example/signed.jpg"}
|
||||
h.outboundRewriter = rewriter
|
||||
|
||||
err := h.Handle(context.Background(), directTextCallback())
|
||||
if err != nil {
|
||||
@@ -1106,8 +1217,11 @@ func TestHandleStreamingStartFailsFallbackAskMarkdown(t *testing.T) {
|
||||
if !reply.called {
|
||||
t.Fatal("SendMarkdown must be called on Start failure")
|
||||
}
|
||||
if reply.lastText != "**fallback markdown**" {
|
||||
t.Fatalf("markdown text = %q, want fallback markdown", reply.lastText)
|
||||
if strings.Contains(reply.lastText, resource) || !strings.Contains(reply.lastText, "https://img.example/signed.jpg") {
|
||||
t.Fatalf("markdown text = %q, want rewritten fallback markdown", reply.lastText)
|
||||
}
|
||||
if rewriter.calls != 1 {
|
||||
t.Fatalf("rewrite calls=%d, want 1", rewriter.calls)
|
||||
}
|
||||
|
||||
// No card updates
|
||||
@@ -1561,15 +1675,16 @@ func TestRenderProgressEventUsesOnlySafeFixedText(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleEventStreamingFinalizesLatestAnswerWithoutMarkdown(t *testing.T) {
|
||||
rawAnswer := "完整答案[[ref id=\"c8\"]]"
|
||||
card := &fakeStreamingCard{startID: "track-events"}
|
||||
qa := &fakeEventQA{
|
||||
fakeStreamingQA: fakeStreamingQA{result: core.QAResult{Answer: "完整答案"}},
|
||||
fakeStreamingQA: fakeStreamingQA{result: core.QAResult{Answer: rawAnswer}},
|
||||
events: []weknora.StreamEvent{
|
||||
{ResponseType: "tool_call", ToolName: "query_understand"},
|
||||
{ResponseType: "tool_call", ToolName: "knowledge_search"},
|
||||
{ResponseType: "references", ReferenceCount: 3},
|
||||
{ResponseType: "answer", FullAnswer: "部分"},
|
||||
{ResponseType: "answer", FullAnswer: "完整答案", Done: true},
|
||||
{ResponseType: "answer", FullAnswer: "部分[[ref"},
|
||||
{ResponseType: "answer", FullAnswer: rawAnswer, Done: true},
|
||||
},
|
||||
}
|
||||
reply := &fakeReply{}
|
||||
@@ -1591,6 +1706,9 @@ func TestHandleEventStreamingFinalizesLatestAnswerWithoutMarkdown(t *testing.T)
|
||||
if last.content != "完整答案" || !last.isFinalize || last.isError {
|
||||
t.Fatalf("final update=%+v", last)
|
||||
}
|
||||
if qa.result.Answer != rawAnswer {
|
||||
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
|
||||
}
|
||||
if reply.called {
|
||||
t.Fatal("SendMarkdown must not be called")
|
||||
}
|
||||
@@ -1796,3 +1914,313 @@ func TestDeliveryAuditEventStreamingProductionPath(t *testing.T) {
|
||||
}
|
||||
requireDelivery(t, qa.deliveries, core.DeliverySent, core.DeliveryMethodCard, "event-track-1", "")
|
||||
}
|
||||
|
||||
func TestHandlePassesConversationTitleToQARequest(t *testing.T) {
|
||||
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
|
||||
reply := &fakeReply{}
|
||||
h := newTestHandler(t, qa, reply)
|
||||
|
||||
t.Run("group conversation title propagated", func(t *testing.T) {
|
||||
cb := groupTextCallback()
|
||||
cb.ConversationTitle = "项目讨论群"
|
||||
if err := h.Handle(context.Background(), cb); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if qa.lastReq.ConversationTitle != "项目讨论群" {
|
||||
t.Fatalf("ConversationTitle = %q, want 项目讨论群", qa.lastReq.ConversationTitle)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("direct conversation title is empty", func(t *testing.T) {
|
||||
qa2 := &fakeQA{result: core.QAResult{Answer: "ok"}}
|
||||
h2 := newTestHandler(t, qa2, &fakeReply{})
|
||||
cb := directTextCallback()
|
||||
cb.ConversationTitle = "should not appear"
|
||||
if err := h2.Handle(context.Background(), cb); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if qa2.lastReq.ConversationTitle != "" {
|
||||
t.Fatalf("ConversationTitle = %q, want empty for direct", qa2.lastReq.ConversationTitle)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestActorUnionIDSnapshotPropagation verifies the actor_union_id_snapshot
|
||||
// is populated from the directory profile UnionID when available.
|
||||
func TestActorUnionIDSnapshotPropagation(t *testing.T) {
|
||||
t.Run("resolved profile with union ID captures snapshot", func(t *testing.T) {
|
||||
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
|
||||
reply := &fakeReply{}
|
||||
profiles := &fakeDirectory{profile: directory.Profile{
|
||||
Name: "张三", UnionID: "union-abc-123", DepartmentName: "技术部", Region: "APAC",
|
||||
}}
|
||||
h, err := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{Directory: profiles})
|
||||
if err != nil {
|
||||
t.Fatalf("NewHandlerWithOptions: %v", err)
|
||||
}
|
||||
data := directTextCallback()
|
||||
data.SenderNick = "钉钉昵称"
|
||||
if err := h.Handle(context.Background(), data); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if qa.lastReq.ActorUnionIDSnapshot != "union-abc-123" {
|
||||
t.Fatalf("ActorUnionIDSnapshot = %q, want union-abc-123", qa.lastReq.ActorUnionIDSnapshot)
|
||||
}
|
||||
if qa.lastReq.SenderName != "张三" {
|
||||
t.Fatalf("SenderName = %q, want 张三", qa.lastReq.SenderName)
|
||||
}
|
||||
if qa.lastReq.TriggerPersonSnapshot != "钉钉昵称" {
|
||||
t.Fatalf("TriggerPersonSnapshot = %q, want 钉钉昵称", qa.lastReq.TriggerPersonSnapshot)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("blank profile union leaves snapshot empty", func(t *testing.T) {
|
||||
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
|
||||
reply := &fakeReply{}
|
||||
profiles := &fakeDirectory{profile: directory.Profile{
|
||||
Name: "张三", UnionID: "", DepartmentName: "技术部", Region: "APAC",
|
||||
}}
|
||||
h, err := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{Directory: profiles})
|
||||
if err != nil {
|
||||
t.Fatalf("NewHandlerWithOptions: %v", err)
|
||||
}
|
||||
data := directTextCallback()
|
||||
data.SenderNick = "钉钉昵称"
|
||||
if err := h.Handle(context.Background(), data); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if qa.lastReq.ActorUnionIDSnapshot != "" {
|
||||
t.Fatalf("ActorUnionIDSnapshot = %q, want empty", qa.lastReq.ActorUnionIDSnapshot)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("directory failure leaves union snapshot empty", func(t *testing.T) {
|
||||
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
|
||||
reply := &fakeReply{}
|
||||
profiles := &fakeDirectory{err: errors.New("directory unavailable")}
|
||||
h, err := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{Directory: profiles})
|
||||
if err != nil {
|
||||
t.Fatalf("NewHandlerWithOptions: %v", err)
|
||||
}
|
||||
data := directTextCallback()
|
||||
data.SenderNick = "钉钉昵称"
|
||||
if err := h.Handle(context.Background(), data); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if qa.lastReq.ActorUnionIDSnapshot != "" {
|
||||
t.Fatalf("ActorUnionIDSnapshot = %q, want empty on directory failure", qa.lastReq.ActorUnionIDSnapshot)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no directory leaves union snapshot empty", func(t *testing.T) {
|
||||
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
|
||||
reply := &fakeReply{}
|
||||
h := newTestHandler(t, qa, reply)
|
||||
data := directTextCallback()
|
||||
data.SenderNick = "钉钉昵称"
|
||||
if err := h.Handle(context.Background(), data); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if qa.lastReq.ActorUnionIDSnapshot != "" {
|
||||
t.Fatalf("ActorUnionIDSnapshot = %q, want empty without directory", qa.lastReq.ActorUnionIDSnapshot)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTriggerPersonSnapshotRegression proves the trigger_person_snapshot
|
||||
// contract: the callback SenderNick becomes TriggerPersonSnapshot, the
|
||||
// directory official Name remains SenderName, and a blank callback nick
|
||||
// falls back to the official name.
|
||||
func TestTriggerPersonSnapshotRegression(t *testing.T) {
|
||||
t.Run("callback nick becomes trigger person snapshot", func(t *testing.T) {
|
||||
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
|
||||
reply := &fakeReply{}
|
||||
profiles := &fakeDirectory{profile: directory.Profile{Name: "张三", DepartmentName: "技术部", Region: "APAC"}}
|
||||
h, err := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{Directory: profiles})
|
||||
if err != nil {
|
||||
t.Fatalf("NewHandlerWithOptions: %v", err)
|
||||
}
|
||||
data := directTextCallback()
|
||||
data.SenderNick = "钉钉昵称"
|
||||
if err := h.Handle(context.Background(), data); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if qa.lastReq.TriggerPersonSnapshot != "钉钉昵称" {
|
||||
t.Fatalf("TriggerPersonSnapshot = %q, want 钉钉昵称", qa.lastReq.TriggerPersonSnapshot)
|
||||
}
|
||||
if qa.lastReq.SenderName != "张三" {
|
||||
t.Fatalf("SenderName = %q, want 张三", qa.lastReq.SenderName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("blank callback nick falls back to official name", func(t *testing.T) {
|
||||
qa := &fakeQA{result: core.QAResult{Answer: "ok"}}
|
||||
reply := &fakeReply{}
|
||||
profiles := &fakeDirectory{profile: directory.Profile{Name: "张三", DepartmentName: "技术部", Region: "APAC"}}
|
||||
h, err := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{Directory: profiles})
|
||||
if err != nil {
|
||||
t.Fatalf("NewHandlerWithOptions: %v", err)
|
||||
}
|
||||
data := directTextCallback()
|
||||
data.SenderNick = ""
|
||||
if err := h.Handle(context.Background(), data); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if qa.lastReq.TriggerPersonSnapshot != "张三" {
|
||||
t.Fatalf("TriggerPersonSnapshot = %q, want 张三 on blank nick", qa.lastReq.TriggerPersonSnapshot)
|
||||
}
|
||||
if qa.lastReq.SenderName != "张三" {
|
||||
t.Fatalf("SenderName = %q, want 张三", qa.lastReq.SenderName)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleStreamingRewritesOnlyFinalOutboundImageContent(t *testing.T) {
|
||||
card := &fakeStreamingCard{startID: "track-image-final"}
|
||||
resource := "resource://AbCdEfGhIjKlMnOpQrStUv"
|
||||
answer := "操作说明\n"
|
||||
qa := &fakeStreamingQA{chunks: []string{answer}, result: core.QAResult{Answer: answer}}
|
||||
reply := &fakeReply{}
|
||||
rewriter := &fakeOutboundRewriter{from: resource, to: "https://img.example/signed.jpg"}
|
||||
h := newTestHandlerWithStreaming(t, qa, reply, card)
|
||||
h.outboundRewriter = rewriter
|
||||
|
||||
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(card.updates) < 2 {
|
||||
t.Fatalf("updates=%d", len(card.updates))
|
||||
}
|
||||
if strings.Contains(card.updates[0].content, resource) {
|
||||
t.Fatalf("streaming intermediate content leaked internal resource: %q", card.updates[0].content)
|
||||
}
|
||||
final := card.updates[len(card.updates)-1]
|
||||
if !final.isFinalize || strings.Contains(final.content, resource) || !strings.Contains(final.content, "https://img.example/signed.jpg") {
|
||||
t.Fatalf("final content=%q finalize=%t", final.content, final.isFinalize)
|
||||
}
|
||||
if rewriter.calls != 1 {
|
||||
t.Fatalf("rewrite calls=%d, want 1", rewriter.calls)
|
||||
}
|
||||
if rewriter.partialCalls == 0 {
|
||||
t.Fatal("streaming intermediate content was not sanitized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFiltersReferenceMarkersAtDingTalkDisplayBoundary(t *testing.T) {
|
||||
rawAnswer := "结论[[ref id=\"c2\"]][[ref id=\"c4\"]]。"
|
||||
wantDisplay := "结论。"
|
||||
|
||||
t.Run("final card", func(t *testing.T) {
|
||||
qa := &fakeQA{result: core.QAResult{Answer: rawAnswer}}
|
||||
reply := &fakeReply{}
|
||||
card := &fakeCard{returnID: "track-reference-filter"}
|
||||
h := newTestHandlerWithCard(t, qa, reply, card)
|
||||
|
||||
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if card.lastReq.Content != wantDisplay {
|
||||
t.Fatalf("card content=%q, want %q", card.lastReq.Content, wantDisplay)
|
||||
}
|
||||
if qa.result.Answer != rawAnswer {
|
||||
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("markdown fallback", func(t *testing.T) {
|
||||
qa := &fakeQA{result: core.QAResult{Answer: rawAnswer}}
|
||||
reply := &fakeReply{}
|
||||
card := &fakeCard{err: errors.New("card unavailable")}
|
||||
h := newTestHandlerWithCard(t, qa, reply, card)
|
||||
|
||||
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reply.lastText != wantDisplay {
|
||||
t.Fatalf("markdown content=%q, want %q", reply.lastText, wantDisplay)
|
||||
}
|
||||
if qa.result.Answer != rawAnswer {
|
||||
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleStreamingHoldsBackPartialReferenceMarkers(t *testing.T) {
|
||||
rawAnswer := "答案正文[[ref id=\"c2\"]]继续。"
|
||||
qa := &fakeStreamingQA{
|
||||
chunks: []string{"答案正文[[ref", " id=\"c2\"]]继续。"},
|
||||
result: core.QAResult{Answer: rawAnswer},
|
||||
}
|
||||
reply := &fakeReply{}
|
||||
card := &fakeStreamingCard{startID: "track-stream-reference-filter"}
|
||||
h := newTestHandlerWithStreaming(t, qa, reply, card)
|
||||
|
||||
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(card.updates) < 2 {
|
||||
t.Fatalf("updates=%d, want at least one partial plus final", len(card.updates))
|
||||
}
|
||||
for i, update := range card.updates {
|
||||
if strings.Contains(update.content, "[[ref") {
|
||||
t.Fatalf("update[%d] leaked reference marker: %q", i, update.content)
|
||||
}
|
||||
}
|
||||
if card.updates[0].content != "答案正文" {
|
||||
t.Fatalf("first partial=%q, want dangling marker held back", card.updates[0].content)
|
||||
}
|
||||
final := card.updates[len(card.updates)-1]
|
||||
if !final.isFinalize || final.content != "答案正文继续。" {
|
||||
t.Fatalf("final content=%q finalize=%t", final.content, final.isFinalize)
|
||||
}
|
||||
if qa.result.Answer != rawAnswer {
|
||||
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStreamingPublishesTableOnlyOnFinalUpdate(t *testing.T) {
|
||||
answer := "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n结论"
|
||||
qa := &fakeStreamingQA{
|
||||
chunks: []string{
|
||||
"说明\n\n",
|
||||
"| 检查项",
|
||||
" | 操作要求 |\n",
|
||||
"| --- | --",
|
||||
"- |\n",
|
||||
"| 接地检查 | 上电前确",
|
||||
"认 |\n\n结论",
|
||||
},
|
||||
result: core.QAResult{Answer: answer},
|
||||
}
|
||||
reply := &fakeReply{}
|
||||
card := &fakeStreamingCard{startID: "track-stream-table"}
|
||||
h := newTestHandlerWithStreaming(t, qa, reply, card)
|
||||
callCount := int64(0)
|
||||
h.now = func() time.Time {
|
||||
callCount++
|
||||
return time.Unix(0, callCount*int64(time.Second))
|
||||
}
|
||||
h.cardUpdateInterval = time.Millisecond
|
||||
|
||||
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var partials []string
|
||||
for _, update := range card.updates {
|
||||
if !update.isFinalize {
|
||||
partials = append(partials, update.content)
|
||||
}
|
||||
}
|
||||
wantPartials := []string{"说明\n\n"}
|
||||
if !reflect.DeepEqual(partials, wantPartials) {
|
||||
t.Fatalf("partial updates=%q, want only pre-table stream %q", partials, wantPartials)
|
||||
}
|
||||
final := card.updates[len(card.updates)-1]
|
||||
if !final.isFinalize || final.content != answer {
|
||||
t.Fatalf("final=%+v, want unchanged complete answer", final)
|
||||
}
|
||||
if qa.result.Answer != answer {
|
||||
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
)
|
||||
|
||||
type fakeImagePublisher struct {
|
||||
published []byte
|
||||
mime string
|
||||
url string
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeImagePublisher) Publish(_ context.Context, data []byte, contentType string) (string, error) {
|
||||
f.calls++
|
||||
f.published = data
|
||||
f.mime = contentType
|
||||
return f.url, f.err
|
||||
}
|
||||
|
||||
func TestDecodeImageDataURI(t *testing.T) {
|
||||
raw := []byte("hello-image-bytes")
|
||||
uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
|
||||
|
||||
decoded, err := decodeImageDataURI(uri)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeImageDataURI: %v", err)
|
||||
}
|
||||
if string(decoded) != string(raw) {
|
||||
t.Fatalf("decoded=%q, want %q", decoded, raw)
|
||||
}
|
||||
|
||||
if _, err := decodeImageDataURI("data:image/png;base64"); err == nil {
|
||||
t.Fatal("expected error for missing base64 payload")
|
||||
}
|
||||
if _, err := decodeImageDataURI("no comma at all"); err == nil {
|
||||
t.Fatal("expected error for non-data URI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishQuestionImagePublishesDecodedBytes(t *testing.T) {
|
||||
pub := &fakeImagePublisher{url: "https://weknora-api.example/i/001"}
|
||||
raw := []byte{0x89, 0x50, 0x4e, 0x47}
|
||||
image := core.ImageInput{
|
||||
DataURI: "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw),
|
||||
MIMEType: "image/png",
|
||||
}
|
||||
h := &Handler{imagePublisher: pub}
|
||||
|
||||
got := h.publishQuestionImage(context.Background(), image)
|
||||
if got != "https://weknora-api.example/i/001" {
|
||||
t.Fatalf("url=%q, want published", got)
|
||||
}
|
||||
if pub.calls != 1 || pub.mime != "image/png" || string(pub.published) != string(raw) {
|
||||
t.Fatalf("publisher calls=%d mime=%q bytes=%q", pub.calls, pub.mime, pub.published)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishQuestionImageFailsClosedWithoutURL(t *testing.T) {
|
||||
h := &Handler{imagePublisher: nil}
|
||||
if got := h.publishQuestionImage(context.Background(), core.ImageInput{}); got != "" {
|
||||
t.Fatalf("expected empty URL without publisher, got %q", got)
|
||||
}
|
||||
|
||||
pub := &fakeImagePublisher{err: errors.New("oss down")}
|
||||
h2 := &Handler{imagePublisher: pub}
|
||||
image := core.ImageInput{
|
||||
DataURI: "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("x")),
|
||||
MIMEType: "image/png",
|
||||
}
|
||||
if got := h2.publishQuestionImage(context.Background(), image); got != "" {
|
||||
t.Fatalf("expected empty URL on publish error, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package dingtalk
|
||||
|
||||
import "strings"
|
||||
|
||||
type markdownStreamLine struct {
|
||||
start int
|
||||
text string
|
||||
inFence bool
|
||||
}
|
||||
|
||||
// stableMarkdownPrefix returns the cumulative content that is safe to render
|
||||
// while Markdown is still streaming. Once a table is confirmed, the table and
|
||||
// every following token stay hidden until the terminal card update.
|
||||
func stableMarkdownPrefix(content string) string {
|
||||
lines := splitMarkdownStreamLines(content)
|
||||
for i := 0; i < len(lines); i++ {
|
||||
if lines[i].inFence || !isTableHeaderCandidate(lines[i].text) {
|
||||
continue
|
||||
}
|
||||
|
||||
if i == len(lines)-1 {
|
||||
return content[:lines[i].start]
|
||||
}
|
||||
if lines[i+1].inFence || !isTableDelimiterCandidate(lines[i+1].text) {
|
||||
continue
|
||||
}
|
||||
if !isCompleteTableDelimiter(lines[i+1].text) {
|
||||
return content[:lines[i].start]
|
||||
}
|
||||
// DingTalk only lays out Markdown tables reliably on the terminal
|
||||
// isFinalize=true update. Keep the table and every following partial
|
||||
// token hidden; the authoritative final update bypasses this helper.
|
||||
return content[:lines[i].start]
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func splitMarkdownStreamLines(content string) []markdownStreamLine {
|
||||
lines := make([]markdownStreamLine, 0, strings.Count(content, "\n")+1)
|
||||
fence := ""
|
||||
for start := 0; start < len(content); {
|
||||
end := strings.IndexByte(content[start:], '\n')
|
||||
if end < 0 {
|
||||
end = len(content)
|
||||
} else {
|
||||
end += start + 1
|
||||
}
|
||||
text := strings.TrimSuffix(content[start:end], "\n")
|
||||
text = strings.TrimSuffix(text, "\r")
|
||||
trimmed := strings.TrimSpace(text)
|
||||
line := markdownStreamLine{start: start, text: text, inFence: fence != ""}
|
||||
|
||||
if marker := markdownFenceMarker(trimmed); marker != "" {
|
||||
line.inFence = true
|
||||
if fence == "" {
|
||||
fence = marker
|
||||
} else if marker == fence {
|
||||
fence = ""
|
||||
}
|
||||
}
|
||||
lines = append(lines, line)
|
||||
start = end
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func markdownFenceMarker(trimmed string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "```"):
|
||||
return "```"
|
||||
case strings.HasPrefix(trimmed, "~~~"):
|
||||
return "~~~"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isTableHeaderCandidate(line string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(line), "|")
|
||||
}
|
||||
|
||||
func isTableDelimiterCandidate(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "|") {
|
||||
return false
|
||||
}
|
||||
for _, r := range trimmed {
|
||||
switch r {
|
||||
case '|', ':', '-', ' ', '\t':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isCompleteTableDelimiter(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
trimmed = strings.TrimPrefix(trimmed, "|")
|
||||
trimmed = strings.TrimSuffix(trimmed, "|")
|
||||
cells := strings.Split(trimmed, "|")
|
||||
if len(cells) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, cell := range cells {
|
||||
cell = strings.TrimSpace(cell)
|
||||
cell = strings.TrimPrefix(cell, ":")
|
||||
cell = strings.TrimSuffix(cell, ":")
|
||||
if len(cell) < 3 || strings.Trim(cell, "-") != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
|
||||
)
|
||||
|
||||
type pacedTableEventQA struct {
|
||||
fakeStreamingQA
|
||||
events []weknora.StreamEvent
|
||||
pause time.Duration
|
||||
}
|
||||
|
||||
func (f *pacedTableEventQA) AskEvents(_ context.Context, req core.QARequest, observer weknora.StreamObserver) (core.QAResult, error) {
|
||||
f.lastReq = req
|
||||
for _, event := range f.events {
|
||||
if observer != nil {
|
||||
observer(event)
|
||||
}
|
||||
time.Sleep(f.pause)
|
||||
}
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func TestHandleEventStreamingPublishesTableOnlyOnFinalUpdate(t *testing.T) {
|
||||
answer := "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n结论"
|
||||
qa := &pacedTableEventQA{
|
||||
fakeStreamingQA: fakeStreamingQA{result: core.QAResult{Answer: answer}},
|
||||
events: []weknora.StreamEvent{
|
||||
{ResponseType: "answer", FullAnswer: "说明\n\n"},
|
||||
{ResponseType: "answer", FullAnswer: "说明\n\n| 检查项"},
|
||||
{ResponseType: "answer", FullAnswer: "说明\n\n| 检查项 | 操作要求 |\n| --- | --"},
|
||||
{ResponseType: "answer", FullAnswer: answer, Done: true},
|
||||
},
|
||||
pause: 20 * time.Millisecond,
|
||||
}
|
||||
reply := &fakeReply{}
|
||||
card := &fakeStreamingCard{startID: "track-event-table"}
|
||||
h := newTestHandlerWithStreaming(t, qa, reply, card)
|
||||
h.cardUpdateInterval = time.Millisecond
|
||||
|
||||
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var partials []string
|
||||
for _, update := range card.updates {
|
||||
if !update.isFinalize {
|
||||
partials = append(partials, update.content)
|
||||
}
|
||||
}
|
||||
wantPartials := []string{"说明\n\n"}
|
||||
if !reflect.DeepEqual(partials, wantPartials) {
|
||||
t.Fatalf("event partial updates=%q, want only pre-table stream %q", partials, wantPartials)
|
||||
}
|
||||
final := card.updates[len(card.updates)-1]
|
||||
if !final.isFinalize || final.content != answer {
|
||||
t.Fatalf("final=%+v, want unchanged complete answer", final)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package dingtalk
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStableMarkdownPrefixHoldsCompletedTableUntilFinalization(t *testing.T) {
|
||||
content := "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n表后正文仍在生成"
|
||||
want := "说明\n\n"
|
||||
|
||||
if got := stableMarkdownPrefix(content); got != want {
|
||||
t.Fatalf("stableMarkdownPrefix()=%q, want table and trailing stream held as %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableMarkdownPrefixHoldsBackTableUntilFinalization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "split header",
|
||||
content: "说明\n\n| 检查项",
|
||||
want: "说明\n\n",
|
||||
},
|
||||
{
|
||||
name: "header waiting for delimiter",
|
||||
content: "说明\n\n| 检查项 | 操作要求 |\n",
|
||||
want: "说明\n\n",
|
||||
},
|
||||
{
|
||||
name: "partial delimiter",
|
||||
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --",
|
||||
want: "说明\n\n",
|
||||
},
|
||||
{
|
||||
name: "partial data row",
|
||||
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确",
|
||||
want: "说明\n\n",
|
||||
},
|
||||
{
|
||||
name: "complete table without terminator",
|
||||
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n",
|
||||
want: "说明\n\n",
|
||||
},
|
||||
{
|
||||
name: "complete table followed by blank line",
|
||||
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n后文",
|
||||
want: "说明\n\n",
|
||||
},
|
||||
{
|
||||
name: "complete table followed by heading",
|
||||
content: "| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n## 后续",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "second consecutive table remains hidden",
|
||||
content: "| A | B |\n| --- | --- |\n| 1 | 2 |\n\n| C | D",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := stableMarkdownPrefix(tt.content); got != tt.want {
|
||||
t.Fatalf("stableMarkdownPrefix()=%q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableMarkdownPrefixLeavesNonTablesUnchanged(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{
|
||||
name: "ordinary text",
|
||||
content: "普通段落继续流式显示。",
|
||||
},
|
||||
{
|
||||
name: "ordinary inline pipe",
|
||||
content: "状态使用 ready | running | done 表示。",
|
||||
},
|
||||
{
|
||||
name: "pipe inside fenced code",
|
||||
content: "```text\n| 这不是 | 表格 |\n| --- | --- |\n```",
|
||||
},
|
||||
{
|
||||
name: "completed prose line starting with pipe",
|
||||
content: "| 这是引用样式文本\n下一段普通文字",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := stableMarkdownPrefix(tt.content); got != tt.content {
|
||||
t.Fatalf("stableMarkdownPrefix()=%q, want unchanged %q", got, tt.content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeSettingsRouter struct {
|
||||
called bool
|
||||
handled bool
|
||||
reply string
|
||||
}
|
||||
|
||||
func (f *fakeSettingsRouter) Handle(_ context.Context, in SettingsMessage) (bool, string, error) {
|
||||
f.called = true
|
||||
if in.StaffID != "staff-123" {
|
||||
panic("wrong identity")
|
||||
}
|
||||
return f.handled, f.reply, nil
|
||||
}
|
||||
func TestSettingsCommandBypassesQA(t *testing.T) {
|
||||
qa := &fakeQA{}
|
||||
reply := &fakeReply{}
|
||||
router := &fakeSettingsRouter{handled: true, reply: "settings menu"}
|
||||
h, err := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{SettingsRouter: router})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cb := directTextCallback()
|
||||
cb.Text = "/setting"
|
||||
if err = h.Handle(context.Background(), cb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !router.called {
|
||||
t.Fatal("router not called")
|
||||
}
|
||||
if qa.called {
|
||||
t.Fatal("settings must bypass QA")
|
||||
}
|
||||
if !reply.called || reply.lastText != "settings menu" {
|
||||
t.Fatalf("reply=%+v", reply)
|
||||
}
|
||||
}
|
||||
func TestOrdinaryQuestionStillCallsQA(t *testing.T) {
|
||||
qa := &fakeQA{}
|
||||
reply := &fakeReply{}
|
||||
router := &fakeSettingsRouter{}
|
||||
h, _ := NewHandlerWithOptions(TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, qa, reply, HandlerOptions{SettingsRouter: router})
|
||||
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !qa.called {
|
||||
t.Fatal("ordinary question must call QA")
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
sdkcard "github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
||||
@@ -69,22 +73,37 @@ func BotCallbackFromSDK(data *chatbot.BotCallbackDataModel) (BotCallback, error)
|
||||
}
|
||||
msgtype := strings.ToLower(strings.TrimSpace(data.Msgtype))
|
||||
cb := BotCallback{
|
||||
ConversationType: data.ConversationType,
|
||||
ConversationID: data.ConversationId,
|
||||
Msgtype: msgtype,
|
||||
MsgID: data.MsgId,
|
||||
SenderNick: data.SenderNick,
|
||||
SenderStaffID: data.SenderStaffId,
|
||||
SenderID: data.SenderId,
|
||||
IsInAtList: data.IsInAtList,
|
||||
SessionWebhook: data.SessionWebhook,
|
||||
Text: data.Text.Content,
|
||||
ConversationType: data.ConversationType,
|
||||
ConversationID: data.ConversationId,
|
||||
ConversationTitle: data.ConversationTitle,
|
||||
Msgtype: msgtype,
|
||||
MsgID: data.MsgId,
|
||||
SenderNick: data.SenderNick,
|
||||
SenderStaffID: data.SenderStaffId,
|
||||
SenderID: data.SenderId,
|
||||
IsInAtList: data.IsInAtList,
|
||||
SessionWebhook: data.SessionWebhook,
|
||||
Text: data.Text.Content,
|
||||
}
|
||||
if msgtype == "richtext" {
|
||||
rtMsgtype, rtText, rtCode := parseRichText(data.Content)
|
||||
rtMsgtype, rtText, rtCodes := parseRichText(data.Content)
|
||||
cb.Msgtype = rtMsgtype
|
||||
cb.Text = rtText
|
||||
cb.DownloadCode = rtCode
|
||||
cb.DownloadCodes = rtCodes
|
||||
if len(rtCodes) > 0 {
|
||||
cb.DownloadCode = rtCodes[0]
|
||||
}
|
||||
return cb, nil
|
||||
}
|
||||
if msgtype == "link" {
|
||||
linkURL := extractLinkMessageURL(data.Content)
|
||||
if linkURL == "" {
|
||||
linkURL = safeHTTPURL(data.Text.Content)
|
||||
}
|
||||
if linkURL != "" {
|
||||
cb.Msgtype = msgTypeText
|
||||
cb.Text = linkURL
|
||||
}
|
||||
return cb, nil
|
||||
}
|
||||
if msgtype == "picture" {
|
||||
@@ -93,6 +112,68 @@ func BotCallbackFromSDK(data *chatbot.BotCallbackDataModel) (BotCallback, error)
|
||||
return cb, nil
|
||||
}
|
||||
|
||||
// extractLinkMessageURL extracts an HTTP(S) target from DingTalk link-card
|
||||
// content. Only known URL and envelope keys are traversed so a card image URL
|
||||
// (for example picUrl) cannot be mistaken for the message target.
|
||||
func extractLinkMessageURL(raw interface{}) string {
|
||||
if raw == nil {
|
||||
return ""
|
||||
}
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var value interface{}
|
||||
if json.Unmarshal(b, &value) != nil {
|
||||
return ""
|
||||
}
|
||||
return findLinkMessageURL(value)
|
||||
}
|
||||
|
||||
func findLinkMessageURL(value interface{}) string {
|
||||
object, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
for key, candidate := range object {
|
||||
switch normalizeLinkField(key) {
|
||||
case "messageurl", "linkurl", "singletitleurl", "href", "url":
|
||||
if text, ok := candidate.(string); ok {
|
||||
if target := safeHTTPURL(text); target != "" {
|
||||
return target
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for key, nested := range object {
|
||||
switch normalizeLinkField(key) {
|
||||
case "content", "link", "data", "card":
|
||||
if target := findLinkMessageURL(nested); target != "" {
|
||||
return target
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeLinkField(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
value = strings.ReplaceAll(value, "_", "")
|
||||
return strings.ReplaceAll(value, "-", "")
|
||||
}
|
||||
|
||||
func safeHTTPURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
u, err := url.ParseRequestURI(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.EqualFold(u.Scheme, "https") && !strings.EqualFold(u.Scheme, "http") {
|
||||
return ""
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// parseRichText parses the SDK Content field for richText messages.
|
||||
// It handles three content shapes:
|
||||
// 1. Direct object: {"richText": [...]}
|
||||
@@ -100,19 +181,19 @@ func BotCallbackFromSDK(data *chatbot.BotCallbackDataModel) (BotCallback, error)
|
||||
// 3. Nested envelope: {"content":{"richText":[...]}}
|
||||
//
|
||||
// Picture items are identified by a "type" field (case-insensitive trimmed)
|
||||
// equal to "picture". The first non-empty downloadCode is used; subsequent
|
||||
// picture items are ignored. Text items are joined with newlines.
|
||||
// equal to "picture". All non-empty download codes are collected in order;
|
||||
// text items are joined with newlines.
|
||||
//
|
||||
// Returns: (msgtype, text, downloadCode). Msgtype is "picture" if any picture
|
||||
// Returns: (msgtype, text, downloadCodes). Msgtype is "picture" if any picture
|
||||
// item exists, "text" if only text items, "richtext" if malformed/empty.
|
||||
func parseRichText(raw interface{}) (string, string, string) {
|
||||
func parseRichText(raw interface{}) (string, string, []string) {
|
||||
items := extractRichTextItems(raw)
|
||||
if len(items) == 0 {
|
||||
return "richtext", "", ""
|
||||
return "richtext", "", nil
|
||||
}
|
||||
|
||||
var textParts []string
|
||||
var downloadCode string
|
||||
var downloadCodes []string
|
||||
hasPicture := false
|
||||
|
||||
for _, item := range items {
|
||||
@@ -120,11 +201,9 @@ func parseRichText(raw interface{}) (string, string, string) {
|
||||
|
||||
if itemType == "picture" {
|
||||
hasPicture = true
|
||||
if downloadCode == "" {
|
||||
code := strings.TrimSpace(item.DownloadCode)
|
||||
if code != "" {
|
||||
downloadCode = code
|
||||
}
|
||||
code := strings.TrimSpace(item.DownloadCode)
|
||||
if code != "" {
|
||||
downloadCodes = append(downloadCodes, code)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -138,12 +217,12 @@ func parseRichText(raw interface{}) (string, string, string) {
|
||||
text := strings.Join(textParts, "\n")
|
||||
|
||||
if hasPicture {
|
||||
return "picture", text, downloadCode
|
||||
return "picture", text, downloadCodes
|
||||
}
|
||||
if text != "" {
|
||||
return "text", text, ""
|
||||
return "text", text, nil
|
||||
}
|
||||
return "richtext", "", ""
|
||||
return "richtext", "", nil
|
||||
}
|
||||
|
||||
// richTextItem is a single item in a richText array.
|
||||
@@ -221,18 +300,77 @@ func extractDownloadCode(raw interface{}) string {
|
||||
return strings.TrimSpace(nested.Content.DownloadCode)
|
||||
}
|
||||
|
||||
// StreamClient wraps the official DingTalk Stream SDK client. It wires
|
||||
// robot callbacks into the existing Handler synchronously (no goroutines,
|
||||
// no queue). The SDK default auto-reconnect stays enabled while running;
|
||||
// Close disables it so the connection stays closed.
|
||||
const callbackDedupTTL = 5 * time.Minute
|
||||
|
||||
type chatCallbackHandler interface {
|
||||
Handle(context.Context, BotCallback) error
|
||||
}
|
||||
|
||||
// newAsyncChatCallbackRouter acknowledges valid DingTalk frames immediately,
|
||||
// then processes them outside the SDK callback lifetime. DingTalk retries slow
|
||||
// callbacks, so retaining MsgIDs for the platform's five-minute retry window
|
||||
// prevents a retry from creating a second streaming card.
|
||||
func newAsyncChatCallbackRouter(handler chatCallbackHandler) func(context.Context, *chatbot.BotCallbackDataModel) ([]byte, error) {
|
||||
var mu sync.Mutex
|
||||
seen := make(map[string]time.Time)
|
||||
return func(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
|
||||
cb, err := BotCallbackFromSDK(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sourceType := safeCallbackMessageType(data.Msgtype)
|
||||
if sourceType == "link" || sourceType == "richtext" {
|
||||
log.Printf("DingTalk callback normalized: source_msgtype=%s normalized_msgtype=%s text_present=%t", sourceType, safeCallbackMessageType(cb.Msgtype), strings.TrimSpace(cb.Text) != "")
|
||||
}
|
||||
messageID := strings.TrimSpace(cb.MsgID)
|
||||
if messageID == "" {
|
||||
return nil, errors.New("dingtalk stream: msgId is required")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
mu.Lock()
|
||||
for id, expiresAt := range seen {
|
||||
if !expiresAt.After(now) {
|
||||
delete(seen, id)
|
||||
}
|
||||
}
|
||||
if expiresAt, exists := seen[messageID]; exists && expiresAt.After(now) {
|
||||
mu.Unlock()
|
||||
return nil, nil
|
||||
}
|
||||
seen[messageID] = now.Add(callbackDedupTTL)
|
||||
mu.Unlock()
|
||||
|
||||
workCtx := context.WithoutCancel(ctx)
|
||||
go func() {
|
||||
if err := handler.Handle(workCtx, cb); err != nil {
|
||||
log.Printf("DingTalk async callback failed: stage=handler")
|
||||
}
|
||||
}()
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func safeCallbackMessageType(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "text", "picture", "richtext", "link":
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
// StreamClient wraps the official DingTalk Stream SDK client. Robot callbacks
|
||||
// are acknowledged before the potentially slow QA/card work begins. The SDK
|
||||
// default auto-reconnect stays enabled while running; Close disables it so the
|
||||
// connection stays closed.
|
||||
type StreamClient struct {
|
||||
inner *dtsdk.StreamClient
|
||||
}
|
||||
|
||||
// NewStreamClient validates the config, builds the SDK client with the app
|
||||
// credential and registers the chatbot callback router. The handler is
|
||||
// invoked synchronously from the SDK callback; its return value only
|
||||
// acknowledges the frame, replies always go through the session webhook.
|
||||
// acknowledged immediately; replies always go through the session webhook.
|
||||
func NewStreamClient(config StreamConfig, handler *Handler, feedbackHandlers ...*CardFeedbackHandler) (*StreamClient, error) {
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, err
|
||||
@@ -255,16 +393,7 @@ func NewStreamClient(config StreamConfig, handler *Handler, feedbackHandlers ...
|
||||
inner := dtsdk.NewStreamClient(
|
||||
dtsdk.WithAppCredential(cred),
|
||||
)
|
||||
inner.RegisterChatBotCallbackRouter(func(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
|
||||
cb, err := BotCallbackFromSDK(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := handler.Handle(ctx, cb); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
inner.RegisterChatBotCallbackRouter(newAsyncChatCallbackRouter(handler))
|
||||
if len(feedbackHandlers) == 1 {
|
||||
inner.RegisterCardCallbackRouter(func(ctx context.Context, request *sdkcard.CardRequest) (*sdkcard.CardResponse, error) {
|
||||
return feedbackHandlers[0].HandleSDK(ctx, request)
|
||||
|
||||
@@ -1,12 +1,80 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
||||
)
|
||||
|
||||
type blockingCallbackHandler struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
}
|
||||
|
||||
func (h *blockingCallbackHandler) Handle(_ context.Context, _ BotCallback) error {
|
||||
h.mu.Lock()
|
||||
h.calls++
|
||||
if h.calls == 1 {
|
||||
close(h.started)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
<-h.release
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *blockingCallbackHandler) callCount() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.calls
|
||||
}
|
||||
|
||||
func TestAsyncChatCallbackAcknowledgesBeforeWorkAndDeduplicatesInflightMessage(t *testing.T) {
|
||||
handler := &blockingCallbackHandler{started: make(chan struct{}), release: make(chan struct{})}
|
||||
router := newAsyncChatCallbackRouter(handler)
|
||||
data := &chatbot.BotCallbackDataModel{
|
||||
ConversationType: "1",
|
||||
Msgtype: "text",
|
||||
MsgId: "same-message",
|
||||
SenderStaffId: "staff-1",
|
||||
SessionWebhook: "https://oapi.dingtalk.com/robot/sendBySession?session=test",
|
||||
Text: chatbot.BotCallbackDataTextModel{Content: "hello"},
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := router(context.Background(), data)
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("callback ACK waited for slow QA work")
|
||||
}
|
||||
select {
|
||||
case <-handler.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("async handler did not start")
|
||||
}
|
||||
if _, err := router(context.Background(), data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if got := handler.callCount(); got != 1 {
|
||||
t.Fatalf("same in-flight MsgID handled %d times, want 1", got)
|
||||
}
|
||||
close(handler.release)
|
||||
}
|
||||
|
||||
func TestStreamConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
@@ -119,7 +187,7 @@ func TestBotCallbackFromSDK(t *testing.T) {
|
||||
SessionWebhook: "https://oapi.dingtalk.com/robot/sendBySession?session=abc",
|
||||
Text: " 你好 ",
|
||||
}
|
||||
if cb != want {
|
||||
if !reflect.DeepEqual(cb, want) {
|
||||
t.Fatalf("BotCallbackFromSDK() = %+v, want %+v", cb, want)
|
||||
}
|
||||
})
|
||||
@@ -142,6 +210,46 @@ func TestBotCallbackFromSDK(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestBotCallbackFromSDKLinkCardPromotesTrustedURLToText(t *testing.T) {
|
||||
t.Parallel()
|
||||
const notableURL = "https://alidocs.dingtalk.com/i/nodes/base?sheetId=sheet&viewId=view"
|
||||
tests := []struct {
|
||||
name string
|
||||
content interface{}
|
||||
}{
|
||||
{name: "direct content", content: map[string]interface{}{"title": "FAQ表", "messageUrl": notableURL}},
|
||||
{name: "nested callback envelope", content: map[string]interface{}{"content": map[string]interface{}{"messageUrl": notableURL}}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb, err := BotCallbackFromSDK(&chatbot.BotCallbackDataModel{Msgtype: "link", MsgId: "link-1", Content: tt.content})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cb.Msgtype != "text" || cb.Text != notableURL {
|
||||
t.Fatalf("callback=%+v, want promoted text URL", cb)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotCallbackFromSDKLinkCardRejectsUnsafeURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb, err := BotCallbackFromSDK(&chatbot.BotCallbackDataModel{
|
||||
Msgtype: "link",
|
||||
MsgId: "link-unsafe",
|
||||
Content: map[string]interface{}{"messageUrl": "javascript:alert(1)"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cb.Msgtype != "link" || cb.Text != "" {
|
||||
t.Fatalf("unsafe link must not be promoted: %+v", cb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotCallbackFromSDKPicture(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -343,7 +451,7 @@ func TestBotCallbackFromSDKRichText(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("first non-empty downloadCode used, subsequent ignored", func(t *testing.T) {
|
||||
t.Run("all picture downloadCodes collected in order", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := &chatbot.BotCallbackDataModel{
|
||||
ConversationType: "1",
|
||||
@@ -361,6 +469,9 @@ func TestBotCallbackFromSDKRichText(t *testing.T) {
|
||||
if cb.DownloadCode != "code-first" {
|
||||
t.Fatalf("DownloadCode = %q, want first code-first", cb.DownloadCode)
|
||||
}
|
||||
if len(cb.DownloadCodes) != 2 || cb.DownloadCodes[0] != "code-first" || cb.DownloadCodes[1] != "code-second" {
|
||||
t.Fatalf("DownloadCodes = %+v, want [code-first code-second]", cb.DownloadCodes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("picture item missing downloadCode still picture", func(t *testing.T) {
|
||||
@@ -542,6 +653,46 @@ func TestBotCallbackFromSDKRichText(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestBotCallbackFromSDKConversationTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("conversation title survives conversion", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := &chatbot.BotCallbackDataModel{
|
||||
ConversationType: "2",
|
||||
ConversationId: "cid123",
|
||||
Msgtype: "text",
|
||||
MsgId: "msg-1",
|
||||
ConversationTitle: "项目讨论群",
|
||||
Text: chatbot.BotCallbackDataTextModel{Content: "hello"},
|
||||
}
|
||||
cb, err := BotCallbackFromSDK(data)
|
||||
if err != nil {
|
||||
t.Fatalf("BotCallbackFromSDK() error = %v", err)
|
||||
}
|
||||
if cb.ConversationTitle != "项目讨论群" {
|
||||
t.Fatalf("ConversationTitle = %q, want 项目讨论群", cb.ConversationTitle)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("conversation title empty for direct", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := &chatbot.BotCallbackDataModel{
|
||||
ConversationType: "1",
|
||||
Msgtype: "text",
|
||||
MsgId: "msg-2",
|
||||
Text: chatbot.BotCallbackDataTextModel{Content: "hi"},
|
||||
}
|
||||
cb, err := BotCallbackFromSDK(data)
|
||||
if err != nil {
|
||||
t.Fatalf("BotCallbackFromSDK() error = %v", err)
|
||||
}
|
||||
if cb.ConversationTitle != "" {
|
||||
t.Fatalf("ConversationTitle = %q, want empty for direct", cb.ConversationTitle)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewStreamClientValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := mustTestHandler(t)
|
||||
|
||||
+60
-41
@@ -17,13 +17,15 @@ var ErrTurnInProgress = errors.New("turn is already processing")
|
||||
|
||||
// ImageInput contains the image data for a QA request. The DataURI is a
|
||||
// full data: URI (e.g. "data:image/png;base64,...") and must never be
|
||||
// persisted to the audit log.
|
||||
// persisted to the audit log. PublishedURL is an optional stable HTTPS URL
|
||||
// produced after the image is published to object storage for traceability.
|
||||
type ImageInput struct {
|
||||
DataURI string
|
||||
MIMEType string
|
||||
SizeBytes int64
|
||||
Width int
|
||||
Height int
|
||||
DataURI string
|
||||
MIMEType string
|
||||
SizeBytes int64
|
||||
Width int
|
||||
Height int
|
||||
PublishedURL string
|
||||
}
|
||||
|
||||
// MediaAudit records metadata about media in a turn for audit purposes.
|
||||
@@ -37,16 +39,22 @@ type MediaAudit struct {
|
||||
}
|
||||
|
||||
type QARequest struct {
|
||||
TenantKey string
|
||||
Channel string
|
||||
ConversationType ConversationType
|
||||
ExternalChatID string
|
||||
ExternalUserID string
|
||||
ExternalMessageID string
|
||||
SenderName string
|
||||
Query string
|
||||
Images []ImageInput
|
||||
MessageType string
|
||||
TenantKey string
|
||||
Channel string
|
||||
ConversationType ConversationType
|
||||
ConversationTitle string
|
||||
ExternalChatID string
|
||||
ExternalUserID string
|
||||
ExternalMessageID string
|
||||
SenderName string
|
||||
TriggerPersonSnapshot string
|
||||
ActorUnionIDSnapshot string
|
||||
Department string
|
||||
Region string
|
||||
ResponseLocale string
|
||||
Query string
|
||||
Images []ImageInput
|
||||
MessageType string
|
||||
}
|
||||
|
||||
type Reference struct {
|
||||
@@ -77,34 +85,44 @@ type SessionMapping struct {
|
||||
ConversationType ConversationType
|
||||
ExternalSubjectID string
|
||||
WeKnoraSessionID string
|
||||
DisplayTitle string
|
||||
}
|
||||
|
||||
type SessionTitleUpdate func(ctx context.Context, sessionID, title string) error
|
||||
|
||||
type Turn struct {
|
||||
ID string
|
||||
TenantKey string
|
||||
Channel string
|
||||
ExternalMessageID string
|
||||
SessionKey string
|
||||
WeKnoraSessionID string
|
||||
WeKnoraRequestID string
|
||||
AgentID string
|
||||
ConversationType ConversationType
|
||||
ExternalChatID string
|
||||
ExternalUserID string
|
||||
SenderName string
|
||||
MessageType string
|
||||
ActorID string
|
||||
ConversationID string
|
||||
RequestMessageID string
|
||||
AnswerMessageID string
|
||||
AnswerDeliveryStatus string
|
||||
AnswerDeliveryMethod string
|
||||
Question string
|
||||
Answer string
|
||||
References []Reference
|
||||
Media []MediaAudit
|
||||
Status string
|
||||
ErrorMessage string
|
||||
ID string
|
||||
TenantKey string
|
||||
Channel string
|
||||
ExternalMessageID string
|
||||
SessionKey string
|
||||
WeKnoraSessionID string
|
||||
WeKnoraRequestID string
|
||||
AgentID string
|
||||
ConversationType ConversationType
|
||||
ConversationTitle string
|
||||
ExternalChatID string
|
||||
ExternalUserID string
|
||||
SenderName string
|
||||
TriggerPersonSnapshot string
|
||||
ActorUnionIDSnapshot string
|
||||
DepartmentSnapshot string
|
||||
RegionSnapshot string
|
||||
MessageType string
|
||||
ActorID string
|
||||
ConversationID string
|
||||
RequestMessageID string
|
||||
AnswerMessageID string
|
||||
AnswerDeliveryStatus string
|
||||
AnswerDeliveryMethod string
|
||||
Question string
|
||||
Answer string
|
||||
References []Reference
|
||||
Media []MediaAudit
|
||||
QuestionImageURLs []string
|
||||
KnowledgeSearched bool
|
||||
Status string
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
// Delivery status constants for outbound messages.
|
||||
@@ -143,6 +161,7 @@ var ErrDeliveryUpdateFailed = errors.New("delivery update failed: RowsAffected !
|
||||
type Repository interface {
|
||||
BeginTurn(ctx context.Context, turn Turn) (Turn, bool, error)
|
||||
ResolveSession(ctx context.Context, mapping SessionMapping, create func(context.Context) (string, error)) (string, error)
|
||||
SyncSessionTitle(ctx context.Context, mapping SessionMapping, title string, update SessionTitleUpdate) error
|
||||
FinishTurn(ctx context.Context, turn Turn) error
|
||||
RecordDelivery(ctx context.Context, du DeliveryUpdate) error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package coverage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// FailureCodeDingtalkGroupSendFailed is the stable failure code used when
|
||||
// a dingtalk group send fails. Raw errors must never be persisted.
|
||||
FailureCodeDingtalkGroupSendFailed = "dingtalk_group_send_failed"
|
||||
)
|
||||
|
||||
// RunOnce claims a batch of pending support cases and sends each one via the
|
||||
// Notifier. The parent context is checked before the claim. Each item gets a
|
||||
// per-item timeout derived from d.SendTimeout. On success, the item is marked
|
||||
// notified; on failure, it is marked with a stable failure code — raw errors
|
||||
// are never persisted. Mark/Claim infrastructure errors are returned with
|
||||
// stable wrapping. An empty batch is a successful no-op.
|
||||
func (d *Dispatcher) RunOnce(ctx context.Context) (DispatcherStats, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return DispatcherStats{}, err
|
||||
}
|
||||
|
||||
staleBefore := time.Now().Add(-d.ClaimLease)
|
||||
cases, err := d.repo.Claim(ctx, d.BatchSize, d.MaxAttempts, staleBefore)
|
||||
if err != nil {
|
||||
return DispatcherStats{}, fmt.Errorf("dispatcher claim: %w", err)
|
||||
}
|
||||
|
||||
stats := DispatcherStats{Claimed: len(cases)}
|
||||
if stats.Claimed == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
for _, sc := range cases {
|
||||
// Check parent context before each item.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
|
||||
sendCtx, cancel := context.WithTimeout(ctx, d.SendTimeout)
|
||||
sendErr := d.notifier.Send(sendCtx, sc.SupportNotification)
|
||||
cancel()
|
||||
|
||||
if sendErr != nil {
|
||||
// Only persist the stable failure code, never the raw error.
|
||||
if err := d.repo.MarkFailed(ctx, sc.CaseID, FailureCodeDingtalkGroupSendFailed); err != nil {
|
||||
return stats, fmt.Errorf("dispatcher mark failed: %w", err)
|
||||
}
|
||||
stats.Failed++
|
||||
continue
|
||||
}
|
||||
|
||||
if err := d.repo.MarkNotified(ctx, sc.CaseID); err != nil {
|
||||
return stats, fmt.Errorf("dispatcher mark notified: %w", err)
|
||||
}
|
||||
stats.Notified++
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
package coverage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
)
|
||||
|
||||
// stubNotificationRepository is a test double for NotificationRepository.
|
||||
type stubNotificationRepository struct {
|
||||
mu sync.Mutex
|
||||
cases []PendingSupportCase
|
||||
claimErr error
|
||||
markErr error
|
||||
markFailedCalls map[string]string // caseID -> failureCode
|
||||
markNotifiedIDs []string
|
||||
}
|
||||
|
||||
func (s *stubNotificationRepository) Claim(ctx context.Context, limit, maxAttempts int, staleBefore time.Time) ([]PendingSupportCase, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.claimErr != nil {
|
||||
return nil, s.claimErr
|
||||
}
|
||||
// Return a copy of the current cases up to limit
|
||||
available := make([]PendingSupportCase, len(s.cases))
|
||||
copy(available, s.cases)
|
||||
if len(available) > limit {
|
||||
available = available[:limit]
|
||||
}
|
||||
s.cases = s.cases[len(available):]
|
||||
return available, nil
|
||||
}
|
||||
|
||||
func (s *stubNotificationRepository) MarkNotified(ctx context.Context, caseID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.markErr != nil {
|
||||
return s.markErr
|
||||
}
|
||||
s.markNotifiedIDs = append(s.markNotifiedIDs, caseID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubNotificationRepository) MarkFailed(ctx context.Context, caseID string, failureCode string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.markErr != nil {
|
||||
return s.markErr
|
||||
}
|
||||
if s.markFailedCalls == nil {
|
||||
s.markFailedCalls = make(map[string]string)
|
||||
}
|
||||
s.markFailedCalls[caseID] = failureCode
|
||||
return nil
|
||||
}
|
||||
|
||||
// stubNotifier is a test double for Notifier.
|
||||
type stubNotifier struct {
|
||||
mu sync.Mutex
|
||||
sendErr error
|
||||
sent []SupportNotification
|
||||
// failOn maps caseID -> error to simulate per-item failure.
|
||||
failOn map[string]error
|
||||
}
|
||||
|
||||
func (s *stubNotifier) Send(ctx context.Context, notif SupportNotification) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err, ok := s.failOn[notif.CaseID]; ok {
|
||||
return err
|
||||
}
|
||||
if s.sendErr != nil {
|
||||
return s.sendErr
|
||||
}
|
||||
s.sent = append(s.sent, notif)
|
||||
return nil
|
||||
}
|
||||
|
||||
// blockingNotifier blocks until ctx is done, then returns ctx.Err().
|
||||
// Used for timeout tests.
|
||||
type blockingNotifier struct {
|
||||
mu sync.Mutex
|
||||
blocked bool
|
||||
}
|
||||
|
||||
func (b *blockingNotifier) Send(ctx context.Context, notif SupportNotification) error {
|
||||
b.mu.Lock()
|
||||
b.blocked = true
|
||||
b.mu.Unlock()
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func makePendingCase(id string, attempts int) PendingSupportCase {
|
||||
return PendingSupportCase{
|
||||
SupportNotification: SupportNotification{
|
||||
CaseID: id,
|
||||
Question: "test question " + id,
|
||||
SenderName: "Sender " + id,
|
||||
Channel: "dingtalk",
|
||||
ConversationType: core.ConversationGroup,
|
||||
AskedAt: time.Unix(1, 0),
|
||||
PreviousQuestions: []string{"previous question"},
|
||||
FAQRecordID: "record-" + id,
|
||||
},
|
||||
Attempts: attempts,
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatcherEmptyQueue verifies that RunOnce on an empty queue returns
|
||||
// a zero stats with no error.
|
||||
func TestDispatcherEmptyQueue(t *testing.T) {
|
||||
repo := &stubNotificationRepository{}
|
||||
notifier := &stubNotifier{}
|
||||
dispatcher, err := NewDispatcher(repo, notifier)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDispatcher: %v", err)
|
||||
}
|
||||
|
||||
stats, err := dispatcher.RunOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if stats.Claimed != 0 || stats.Notified != 0 || stats.Failed != 0 {
|
||||
t.Fatalf("empty queue: got %+v, want all zero", stats)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatcherAllSuccess verifies that all claimed cases are sent and
|
||||
// marked as notified.
|
||||
func TestDispatcherAllSuccess(t *testing.T) {
|
||||
repo := &stubNotificationRepository{
|
||||
cases: []PendingSupportCase{
|
||||
makePendingCase("case-1", 0),
|
||||
makePendingCase("case-2", 0),
|
||||
makePendingCase("case-3", 0),
|
||||
},
|
||||
}
|
||||
notifier := &stubNotifier{}
|
||||
dispatcher, err := NewDispatcher(repo, notifier, WithBatchSize(10))
|
||||
if err != nil {
|
||||
t.Fatalf("NewDispatcher: %v", err)
|
||||
}
|
||||
|
||||
stats, err := dispatcher.RunOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if stats.Claimed != 3 {
|
||||
t.Fatalf("Claimed = %d, want 3", stats.Claimed)
|
||||
}
|
||||
if stats.Notified != 3 {
|
||||
t.Fatalf("Notified = %d, want 3", stats.Notified)
|
||||
}
|
||||
if stats.Failed != 0 {
|
||||
t.Fatalf("Failed = %d, want 0", stats.Failed)
|
||||
}
|
||||
if len(notifier.sent) != 3 {
|
||||
t.Fatalf("sent %d, want 3", len(notifier.sent))
|
||||
}
|
||||
if len(repo.markNotifiedIDs) != 3 {
|
||||
t.Fatalf("markNotifiedIDs = %d, want 3", len(repo.markNotifiedIDs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatcherOneFailureContinues verifies that when one item fails,
|
||||
// the dispatcher continues with the rest and marks the failed one.
|
||||
func TestDispatcherOneFailureContinues(t *testing.T) {
|
||||
repo := &stubNotificationRepository{
|
||||
cases: []PendingSupportCase{
|
||||
makePendingCase("case-1", 0),
|
||||
makePendingCase("case-2", 0), // will fail
|
||||
makePendingCase("case-3", 0),
|
||||
},
|
||||
}
|
||||
notifier := &stubNotifier{
|
||||
failOn: map[string]error{
|
||||
"case-2": errors.New("simulated send failure"),
|
||||
},
|
||||
}
|
||||
dispatcher, err := NewDispatcher(repo, notifier, WithBatchSize(10))
|
||||
if err != nil {
|
||||
t.Fatalf("NewDispatcher: %v", err)
|
||||
}
|
||||
|
||||
stats, err := dispatcher.RunOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if stats.Claimed != 3 {
|
||||
t.Fatalf("Claimed = %d, want 3", stats.Claimed)
|
||||
}
|
||||
if stats.Notified != 2 {
|
||||
t.Fatalf("Notified = %d, want 2", stats.Notified)
|
||||
}
|
||||
if stats.Failed != 1 {
|
||||
t.Fatalf("Failed = %d, want 1", stats.Failed)
|
||||
}
|
||||
if notifier.sent[0].CaseID != "case-1" {
|
||||
t.Fatalf("first sent = %s, want case-1", notifier.sent[0].CaseID)
|
||||
}
|
||||
if notifier.sent[1].CaseID != "case-3" {
|
||||
t.Fatalf("second sent = %s, want case-3", notifier.sent[1].CaseID)
|
||||
}
|
||||
// case-2 must be marked as failed with stable code, not raw error
|
||||
if code, ok := repo.markFailedCalls["case-2"]; !ok {
|
||||
t.Fatal("case-2 must be marked as failed")
|
||||
} else if code != "dingtalk_group_send_failed" {
|
||||
t.Fatalf("failure code = %q, want dingtalk_group_send_failed", code)
|
||||
}
|
||||
// case-1 and case-3 must be marked notified
|
||||
foundNotified := make(map[string]bool)
|
||||
for _, id := range repo.markNotifiedIDs {
|
||||
foundNotified[id] = true
|
||||
}
|
||||
if !foundNotified["case-1"] {
|
||||
t.Fatal("case-1 must be marked notified")
|
||||
}
|
||||
if !foundNotified["case-3"] {
|
||||
t.Fatal("case-3 must be marked notified")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatcherFailureStableCodeOnly verifies that the raw error text is
|
||||
// never written to the repository — only the stable failure code.
|
||||
func TestDispatcherFailureStableCodeOnly(t *testing.T) {
|
||||
repo := &stubNotificationRepository{
|
||||
cases: []PendingSupportCase{
|
||||
makePendingCase("case-1", 0),
|
||||
},
|
||||
}
|
||||
notifier := &stubNotifier{
|
||||
failOn: map[string]error{
|
||||
"case-1": errors.New("secret token xyz123 leaked in error"),
|
||||
},
|
||||
}
|
||||
dispatcher, err := NewDispatcher(repo, notifier)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDispatcher: %v", err)
|
||||
}
|
||||
|
||||
_, err = dispatcher.RunOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if code := repo.markFailedCalls["case-1"]; code != "dingtalk_group_send_failed" {
|
||||
t.Fatalf("failure code = %q, want dingtalk_group_send_failed (raw error must never leak)", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatcherMarkNotifiedErrorPropagation verifies that when
|
||||
// MarkNotified fails, the error is returned to the caller.
|
||||
func TestDispatcherMarkNotifiedErrorPropagation(t *testing.T) {
|
||||
repo := &stubNotificationRepository{
|
||||
cases: []PendingSupportCase{makePendingCase("case-1", 0)},
|
||||
markErr: errors.New("db unreachable"),
|
||||
}
|
||||
notifier := &stubNotifier{}
|
||||
dispatcher, err := NewDispatcher(repo, notifier)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDispatcher: %v", err)
|
||||
}
|
||||
|
||||
_, err = dispatcher.RunOnce(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error from MarkNotified, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatcherMarkFailedErrorPropagation verifies that when MarkFailed
|
||||
// fails, the error is returned to the caller.
|
||||
func TestDispatcherMarkFailedErrorPropagation(t *testing.T) {
|
||||
repo := &stubNotificationRepository{
|
||||
cases: []PendingSupportCase{
|
||||
makePendingCase("case-1", 0),
|
||||
},
|
||||
markErr: errors.New("db unreachable"),
|
||||
}
|
||||
notifier := &stubNotifier{
|
||||
failOn: map[string]error{
|
||||
"case-1": errors.New("send failed"),
|
||||
},
|
||||
}
|
||||
dispatcher, err := NewDispatcher(repo, notifier)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDispatcher: %v", err)
|
||||
}
|
||||
|
||||
_, err = dispatcher.RunOnce(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error from MarkFailed, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatcherClaimErrorPropagation verifies claim errors are returned.
|
||||
func TestDispatcherClaimErrorPropagation(t *testing.T) {
|
||||
repo := &stubNotificationRepository{
|
||||
claimErr: errors.New("db unreachable"),
|
||||
}
|
||||
notifier := &stubNotifier{}
|
||||
dispatcher, err := NewDispatcher(repo, notifier)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDispatcher: %v", err)
|
||||
}
|
||||
|
||||
_, err = dispatcher.RunOnce(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error from Claim, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatcherPerItemTimeout verifies that the dispatcher applies a per-item
|
||||
// timeout: when a notifier blocks forever, RunOnce completes within a
|
||||
// reasonable time, Failed=1, and the stable failure code is used.
|
||||
func TestDispatcherPerItemTimeout(t *testing.T) {
|
||||
repo := &stubNotificationRepository{
|
||||
cases: []PendingSupportCase{makePendingCase("case-1", 0)},
|
||||
}
|
||||
notifier := &blockingNotifier{}
|
||||
// Use a 50ms timeout so the test is not flaky but still fast.
|
||||
dispatcher, err := NewDispatcher(repo, notifier, WithSendTimeout(50*time.Millisecond))
|
||||
if err != nil {
|
||||
t.Fatalf("NewDispatcher: %v", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
stats, err := dispatcher.RunOnce(context.Background())
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if stats.Failed != 1 {
|
||||
t.Fatalf("Failed = %d, want 1", stats.Failed)
|
||||
}
|
||||
if stats.Claimed != 1 {
|
||||
t.Fatalf("Claimed = %d, want 1", stats.Claimed)
|
||||
}
|
||||
// Verify the stable failure code was used
|
||||
if code := repo.markFailedCalls["case-1"]; code != "dingtalk_group_send_failed" {
|
||||
t.Fatalf("failure code = %q, want dingtalk_group_send_failed", code)
|
||||
}
|
||||
// RunOnce must complete in a reasonable time (not stuck forever).
|
||||
// Use a generous upper bound: 5s is enough for any CI environment.
|
||||
if elapsed > 5*time.Second {
|
||||
t.Fatalf("RunOnce took %v, expected to complete within 5s", elapsed)
|
||||
}
|
||||
if !notifier.blocked {
|
||||
t.Fatal("notifier was not called — timeout did not trigger")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatcherContextCancel verifies the dispatcher respects context cancellation.
|
||||
func TestDispatcherContextCancel(t *testing.T) {
|
||||
repo := &stubNotificationRepository{
|
||||
cases: []PendingSupportCase{makePendingCase("case-1", 0)},
|
||||
}
|
||||
notifier := &stubNotifier{}
|
||||
dispatcher, err := NewDispatcher(repo, notifier, WithSendTimeout(10*time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("NewDispatcher: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err = dispatcher.RunOnce(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected context error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatcherConstructorValidation verifies constructor input validation.
|
||||
func TestDispatcherConstructorValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts []DispatcherOption
|
||||
wantErr bool
|
||||
}{
|
||||
{"nil repo", nil, true},
|
||||
{"nil notifier only", nil, true},
|
||||
{"batch too small", []DispatcherOption{WithBatchSize(0)}, true},
|
||||
{"batch too large", []DispatcherOption{WithBatchSize(101)}, true},
|
||||
{"maxAttempts too small", []DispatcherOption{WithMaxAttempts(0)}, true},
|
||||
{"maxAttempts too large", []DispatcherOption{WithMaxAttempts(11)}, true},
|
||||
{"valid defaults", nil, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var repo *stubNotificationRepository
|
||||
var notifier *stubNotifier
|
||||
if tt.name != "nil repo" {
|
||||
repo = &stubNotificationRepository{}
|
||||
}
|
||||
if tt.name != "nil notifier only" {
|
||||
notifier = &stubNotifier{}
|
||||
}
|
||||
_, err := NewDispatcher(repo, notifier, tt.opts...)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatcherBatchSizeLimit verifies batch size is capped at 100.
|
||||
func TestDispatcherBatchSizeLimit(t *testing.T) {
|
||||
repo := &stubNotificationRepository{
|
||||
cases: []PendingSupportCase{},
|
||||
}
|
||||
notifier := &stubNotifier{}
|
||||
d, err := NewDispatcher(repo, notifier, WithBatchSize(100))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.BatchSize != 100 {
|
||||
t.Fatalf("BatchSize = %d, want 100", d.BatchSize)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package coverage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"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{
|
||||
"建议您联系",
|
||||
"建议联系",
|
||||
"请联系",
|
||||
"联系技术支持",
|
||||
"联系官方销售",
|
||||
}
|
||||
|
||||
// Evaluator classifies a successful turn into a coverage verdict.
|
||||
type Evaluator struct{}
|
||||
|
||||
// NewEvaluator creates the current Evaluator.
|
||||
func NewEvaluator() *Evaluator {
|
||||
return &Evaluator{}
|
||||
}
|
||||
|
||||
// Version returns the current evaluator version.
|
||||
func (e *Evaluator) Version() string {
|
||||
return EvaluatorVersion
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
//
|
||||
// v1 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" {
|
||||
return Decision{}, ErrTurnNotSucceeded
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return Decision{
|
||||
TurnID: turn.ID,
|
||||
TenantKey: turn.TenantKey,
|
||||
Channel: turn.Channel,
|
||||
EvaluatorVersion: EvaluatorVersion,
|
||||
Verdict: VerdictUncovered,
|
||||
ReasonCode: ReasonUncoveredExplicitNoAnswer,
|
||||
ReferenceCount: len(turn.References),
|
||||
TopScore: maxScore(turn.References),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Image turns are classified by whether knowledge search occurred.
|
||||
if hasImageMedia(turn.Media) {
|
||||
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
|
||||
}
|
||||
if turn.KnowledgeSearched {
|
||||
return Decision{
|
||||
TurnID: turn.ID,
|
||||
TenantKey: turn.TenantKey,
|
||||
Channel: turn.Channel,
|
||||
EvaluatorVersion: EvaluatorVersion,
|
||||
Verdict: VerdictUncovered,
|
||||
ReasonCode: ReasonUncoveredNoReferences,
|
||||
ReferenceCount: 0,
|
||||
TopScore: 0,
|
||||
}, nil
|
||||
}
|
||||
return Decision{
|
||||
TurnID: turn.ID,
|
||||
TenantKey: turn.TenantKey,
|
||||
Channel: turn.Channel,
|
||||
EvaluatorVersion: EvaluatorVersion,
|
||||
Verdict: VerdictCovered,
|
||||
ReasonCode: ReasonCoveredVisualRecognition,
|
||||
ReferenceCount: 0,
|
||||
TopScore: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// No references → uncovered.
|
||||
if len(turn.References) == 0 {
|
||||
return Decision{
|
||||
TurnID: turn.ID,
|
||||
TenantKey: turn.TenantKey,
|
||||
Channel: turn.Channel,
|
||||
EvaluatorVersion: EvaluatorVersion,
|
||||
Verdict: VerdictUncovered,
|
||||
ReasonCode: ReasonUncoveredNoReferences,
|
||||
ReferenceCount: 0,
|
||||
TopScore: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Has references → covered (v1 never uses score threshold).
|
||||
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
|
||||
}
|
||||
|
||||
// hasImageMedia reports whether the turn carried at least one image input.
|
||||
func hasImageMedia(media []core.MediaAudit) bool {
|
||||
for _, m := range media {
|
||||
if strings.EqualFold(strings.TrimSpace(m.Type), "image") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
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 maxScore(refs []core.Reference) float64 {
|
||||
var max float64
|
||||
for _, r := range refs {
|
||||
if r.Score > max {
|
||||
max = r.Score
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package coverage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
)
|
||||
|
||||
func imageTurn(answer string, refs []core.Reference, searched bool) core.Turn {
|
||||
return core.Turn{
|
||||
ID: "img-turn",
|
||||
Status: "succeeded",
|
||||
MessageType: "image",
|
||||
Answer: answer,
|
||||
References: refs,
|
||||
Media: []core.MediaAudit{{Type: "image", MIMEType: "image/jpeg"}},
|
||||
KnowledgeSearched: searched,
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_ImageVisualRecognitionNoSearch(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := imageTurn("这张图片展示了一个手写的知识库数据处理与发布流程图。", nil, false)
|
||||
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictCovered {
|
||||
t.Fatalf("verdict=%s, want covered (visual recognition is not a coverage miss)", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonCoveredVisualRecognition {
|
||||
t.Fatalf("reason_code=%s, want %s", d.ReasonCode, ReasonCoveredVisualRecognition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_ImageSearchButNoReferencesIsUncovered(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := imageTurn("根据图片中的错误信息,未能在知识库中找到对应的解决方案。", nil, true)
|
||||
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered {
|
||||
t.Fatalf("verdict=%s, want uncovered (image turn searched KB but got no refs)", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredNoReferences {
|
||||
t.Fatalf("reason_code=%s, want %s", d.ReasonCode, ReasonUncoveredNoReferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_ImageWithReferencesIsCovered(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := imageTurn("根据知识库资料,这个是故障代码E5,请检查...", []core.Reference{{ID: "r1", Score: 0.9}}, true)
|
||||
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictCovered {
|
||||
t.Fatalf("verdict=%s, want covered", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonCoveredHasReferences {
|
||||
t.Fatalf("reason_code=%s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_TextTurnNoReferencesStillUncovered(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "text-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "这是一个正常的回答,但没有任何引用",
|
||||
MessageType: "text",
|
||||
References: []core.Reference{},
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package coverage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
)
|
||||
|
||||
func TestEvaluatorVersion(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
if e.Version() != "v3" {
|
||||
t.Errorf("Evaluator version = %q, want v3", e.Version())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_RejectsFailedTurn(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
// Failed turn should be rejected/return error
|
||||
_, err := e.Evaluate(core.Turn{Status: "failed"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for failed turn, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_RejectsProcessingTurn(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
_, err := e.Evaluate(core.Turn{Status: "processing"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for processing turn, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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},
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_UncoveredNoReferences(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "这是一个正常的回答,但没有任何引用",
|
||||
References: []core.Reference{},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered {
|
||||
t.Errorf("verdict = %s, want uncovered", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredNoReferences {
|
||||
t.Errorf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredNoReferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_UncoveredNilReferences(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "这是一个正常的回答,但引用为nil",
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered {
|
||||
t.Errorf("verdict = %s, want uncovered", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredNoReferences {
|
||||
t.Errorf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredNoReferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_CoveredHasReferences(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "根据知识库中的信息,这个问题的答案是...",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.75},
|
||||
{ID: "r2", Score: 0.92},
|
||||
{ID: "r3", Score: 0.60},
|
||||
},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictCovered {
|
||||
t.Errorf("verdict = %s, want covered", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonCoveredHasReferences {
|
||||
t.Errorf("reason_code = %s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_TopScore(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "根据知识库中的信息...",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.75},
|
||||
{ID: "r2", Score: 0.92},
|
||||
{ID: "r3", Score: 0.60},
|
||||
},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.TopScore != 0.92 {
|
||||
t.Errorf("top_score = %f, want 0.92", d.TopScore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_ReferenceCount(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "根据知识库中的信息...",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.75},
|
||||
{ID: "r2", Score: 0.92},
|
||||
{ID: "r3", Score: 0.60},
|
||||
},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.ReferenceCount != 3 {
|
||||
t.Fatalf("reference_count = %d, want 3", d.ReferenceCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_TopScoreZeroWhenNoReferences(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "没有引用",
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.TopScore != 0 {
|
||||
t.Errorf("top_score = %f, want 0", d.TopScore)
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "未找到相关结果,请换个方式提问",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.99},
|
||||
{ID: "r2", Score: 0.88},
|
||||
},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
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.ReasonCode != ReasonUncoveredExplicitNoAnswer {
|
||||
t.Errorf("no-answer with references: reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_NoAnswerPhraseMatchesInsideAnswer(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "根据现有资料,知识库中没有对应说明,请联系技术支持。",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.9},
|
||||
},
|
||||
}
|
||||
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
|
||||
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_ProductionUnavailableAuthorityPhraseTrumpsReferences(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "cra-price-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "当前暂无已发布的权威知识明确说明CRA系列机器人的价格信息。若需要获取对应报价,建议联系越疆官方销售渠道或技术支持进行咨询。",
|
||||
References: []core.Reference{
|
||||
{ID: "ref-1", Score: 0.0164},
|
||||
},
|
||||
}
|
||||
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered {
|
||||
t.Fatalf("verdict = %s, want %s", d.Verdict, VerdictUncovered)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
|
||||
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_RetrievalGapRequiresHumanEscalation(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
cases := []struct {
|
||||
name string
|
||||
answer string
|
||||
verdict string
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "production NOVA price answer",
|
||||
answer: "当前暂未查询到Nova系列机器人的公开销售价格信息。\n\n如需获取报价,建议您联系越疆科技官方销售渠道或对应区域的销售工程师咨询具体价格方案。",
|
||||
verdict: VerdictUncovered,
|
||||
reason: ReasonUncoveredExplicitNoAnswer,
|
||||
},
|
||||
{
|
||||
name: "retrieval gap without escalation is a valid negative result",
|
||||
answer: "系统暂未查询到异常记录,设备运行正常。",
|
||||
verdict: VerdictCovered,
|
||||
reason: ReasonCoveredHasReferences,
|
||||
},
|
||||
{
|
||||
name: "human escalation alone is not a knowledge gap",
|
||||
answer: "设备状态正常,如需维护服务请联系技术支持。",
|
||||
verdict: VerdictCovered,
|
||||
reason: ReasonCoveredHasReferences,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
turn := core.Turn{
|
||||
ID: "nova-price-turn",
|
||||
Status: "succeeded",
|
||||
Answer: tc.answer,
|
||||
References: []core.Reference{
|
||||
{ID: "ref-1", Score: 0.0164},
|
||||
},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate: %v", err)
|
||||
}
|
||||
if d.Verdict != tc.verdict || d.ReasonCode != tc.reason {
|
||||
t.Fatalf("verdict/reason = %s/%s, want %s/%s", d.Verdict, d.ReasonCode, tc.verdict, tc.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_DoesNotExpandBeyondApprovedNoAnswerPhrases(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
answers := []string{
|
||||
"没有相关限制,设备可以正常运行。",
|
||||
"当前暂无已发布价格调整计划,现有价格继续执行。",
|
||||
}
|
||||
for _, answer := range answers {
|
||||
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("answer %q: unexpected error: %v", answer, err)
|
||||
}
|
||||
if d.Verdict != VerdictCovered {
|
||||
t.Fatalf("answer %q: verdict = %s, want covered", answer, d.Verdict)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_NoScoreThreshold(t *testing.T) {
|
||||
// The evaluator must never use score threshold to determine covered/uncovered.
|
||||
// Even with very low score references, if there are references and no explicit
|
||||
// no-answer, it should be covered.
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "这是一个回答,引用分数很低",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.01},
|
||||
},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictCovered {
|
||||
t.Errorf("verdict = %s, want covered (score threshold is not used)", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonCoveredHasReferences {
|
||||
t.Errorf("reason_code = %s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_EmptyAnswerCovered(t *testing.T) {
|
||||
// Empty answer is not a no-answer phrase. With references, it's covered.
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "test-turn",
|
||||
Status: "succeeded",
|
||||
Answer: "",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.5},
|
||||
},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictCovered {
|
||||
t.Errorf("verdict = %s, want covered", d.Verdict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_DecisionHasTurnIdentity(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
turn := core.Turn{
|
||||
ID: "turn-abc",
|
||||
TenantKey: "tenant-1",
|
||||
Channel: "dingtalk",
|
||||
Status: "succeeded",
|
||||
Answer: "正常回答",
|
||||
References: []core.Reference{{ID: "r1", Score: 0.5}},
|
||||
}
|
||||
d, err := e.Evaluate(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.TurnID != "turn-abc" {
|
||||
t.Errorf("turn_id = %q, want turn-abc", d.TurnID)
|
||||
}
|
||||
if d.TenantKey != "tenant-1" {
|
||||
t.Errorf("tenant_key = %q, want tenant-1", d.TenantKey)
|
||||
}
|
||||
if d.Channel != "dingtalk" {
|
||||
t.Errorf("channel = %q, want dingtalk", d.Channel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package coverage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
)
|
||||
|
||||
// SupportNotification is a safe, minimal view of the data needed to send a
|
||||
// notification. It must never contain tokens, webhooks, raw API responses,
|
||||
// raw error text, reference bodies, or group IDs.
|
||||
type SupportNotification struct {
|
||||
CaseID string
|
||||
Question string
|
||||
SenderName string
|
||||
Channel string
|
||||
ConversationType core.ConversationType
|
||||
AskedAt time.Time
|
||||
PreviousQuestions []string
|
||||
FAQRecordID string
|
||||
}
|
||||
|
||||
// PendingSupportCase holds the data needed to send a notification for a
|
||||
// support case that is pending notification.
|
||||
type PendingSupportCase struct {
|
||||
SupportNotification
|
||||
Attempts int
|
||||
}
|
||||
|
||||
// NotificationRepository manages the notification lifecycle of support_cases.
|
||||
type NotificationRepository interface {
|
||||
// Claim atomically claims up to limit pending/retryable/expired-notifying
|
||||
// support cases. Each case must have attempts < maxAttempts. Cases stuck
|
||||
// in notifying with last_notification_at older than staleBefore are also
|
||||
// eligible. Returns the claimed cases with their notification data.
|
||||
Claim(ctx context.Context, limit int, maxAttempts int, staleBefore time.Time) ([]PendingSupportCase, error)
|
||||
|
||||
// MarkNotified transitions a case from notifying to notified.
|
||||
// RowsAffected must be exactly 1.
|
||||
MarkNotified(ctx context.Context, caseID string) error
|
||||
|
||||
// MarkFailed transitions a case from notifying to notification_failed
|
||||
// with a stable failure code. Raw errors must never be written.
|
||||
// RowsAffected must be exactly 1.
|
||||
MarkFailed(ctx context.Context, caseID string, failureCode string) error
|
||||
}
|
||||
|
||||
// Notifier sends a notification. Matches the signature of
|
||||
// dingtalk.SupportGroupSender.Send.
|
||||
type Notifier interface {
|
||||
Send(ctx context.Context, notif SupportNotification) error
|
||||
}
|
||||
|
||||
// DispatcherStats holds sanitized dispatch statistics.
|
||||
type DispatcherStats struct {
|
||||
Claimed int
|
||||
Notified int
|
||||
Failed int
|
||||
}
|
||||
|
||||
// DispatcherOption configures the Dispatcher.
|
||||
type DispatcherOption func(*Dispatcher)
|
||||
|
||||
// WithBatchSize sets the batch size (1..100). Default 10.
|
||||
func WithBatchSize(n int) DispatcherOption {
|
||||
return func(d *Dispatcher) { d.BatchSize = n }
|
||||
}
|
||||
|
||||
// WithMaxAttempts sets the max notification attempts (1..10). Default 3.
|
||||
func WithMaxAttempts(n int) DispatcherOption {
|
||||
return func(d *Dispatcher) { d.MaxAttempts = n }
|
||||
}
|
||||
|
||||
// WithClaimLease sets the stale-notifying lease duration. Default 5 minutes.
|
||||
func WithClaimLease(dur time.Duration) DispatcherOption {
|
||||
return func(d *Dispatcher) { d.ClaimLease = dur }
|
||||
}
|
||||
|
||||
// WithSendTimeout sets the per-item send timeout. Default 30 seconds.
|
||||
func WithSendTimeout(dur time.Duration) DispatcherOption {
|
||||
return func(d *Dispatcher) { d.SendTimeout = dur }
|
||||
}
|
||||
|
||||
// Dispatcher claims pending support cases and sends them via the Notifier.
|
||||
type Dispatcher struct {
|
||||
repo NotificationRepository
|
||||
notifier Notifier
|
||||
BatchSize int
|
||||
MaxAttempts int
|
||||
ClaimLease time.Duration
|
||||
SendTimeout time.Duration
|
||||
}
|
||||
|
||||
// NewDispatcher creates a validated Dispatcher with sensible defaults.
|
||||
// Defaults: BatchSize=10, MaxAttempts=3, ClaimLease=5m, SendTimeout=30s.
|
||||
func NewDispatcher(repo NotificationRepository, notifier Notifier, opts ...DispatcherOption) (*Dispatcher, error) {
|
||||
if isNilDependency(repo) {
|
||||
return nil, errors.New("dispatcher: NotificationRepository is required")
|
||||
}
|
||||
if isNilDependency(notifier) {
|
||||
return nil, errors.New("dispatcher: Notifier is required")
|
||||
}
|
||||
d := &Dispatcher{
|
||||
repo: repo,
|
||||
notifier: notifier,
|
||||
BatchSize: 10,
|
||||
MaxAttempts: 3,
|
||||
ClaimLease: 5 * time.Minute,
|
||||
SendTimeout: 30 * time.Second,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(d)
|
||||
}
|
||||
if d.BatchSize < 1 || d.BatchSize > 100 {
|
||||
return nil, fmt.Errorf("dispatcher: BatchSize must be 1..100, got %d", d.BatchSize)
|
||||
}
|
||||
if d.MaxAttempts < 1 || d.MaxAttempts > 10 {
|
||||
return nil, fmt.Errorf("dispatcher: MaxAttempts must be 1..10, got %d", d.MaxAttempts)
|
||||
}
|
||||
if d.ClaimLease <= 0 || d.ClaimLease > 24*time.Hour {
|
||||
return nil, fmt.Errorf("dispatcher: ClaimLease must be >0 and <=24h, got %v", d.ClaimLease)
|
||||
}
|
||||
if d.SendTimeout <= 0 || d.SendTimeout > 24*time.Hour {
|
||||
return nil, fmt.Errorf("dispatcher: SendTimeout must be >0 and <=24h, got %v", d.SendTimeout)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func isNilDependency(value any) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
v := reflect.ValueOf(value)
|
||||
switch v.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||
return v.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Package coverage defines the domain logic for coverage evaluation.
|
||||
package coverage
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
)
|
||||
|
||||
func TestSupportNotificationCarriesCustomerQuestionContext(t *testing.T) {
|
||||
askedAt := time.Date(2026, 7, 24, 10, 16, 35, 0, time.FixedZone("CST", 8*60*60))
|
||||
notif := SupportNotification{
|
||||
CaseID: "case-001",
|
||||
Question: "Nova5I具体支持哪些通信协议?",
|
||||
SenderName: "张三",
|
||||
Channel: "dingtalk",
|
||||
ConversationType: core.ConversationGroup,
|
||||
AskedAt: askedAt,
|
||||
PreviousQuestions: []string{
|
||||
"Nova5I如何配置Modbus TCP?",
|
||||
"Nova5I是否支持EtherCAT?",
|
||||
},
|
||||
FAQRecordID: "record-001",
|
||||
}
|
||||
|
||||
if notif.Question != "Nova5I具体支持哪些通信协议?" || notif.SenderName != "张三" {
|
||||
t.Fatalf("current question context mismatch: %+v", notif)
|
||||
}
|
||||
if !notif.AskedAt.Equal(askedAt) {
|
||||
t.Fatalf("AskedAt = %v, want %v", notif.AskedAt, askedAt)
|
||||
}
|
||||
if len(notif.PreviousQuestions) != 2 || notif.PreviousQuestions[0] != "Nova5I如何配置Modbus TCP?" {
|
||||
t.Fatalf("PreviousQuestions = %#v", notif.PreviousQuestions)
|
||||
}
|
||||
if notif.FAQRecordID != "record-001" {
|
||||
t.Fatalf("FAQRecordID = %q", notif.FAQRecordID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportNotificationExcludesAnswerAndRetrievalDiagnostics(t *testing.T) {
|
||||
typ := reflect.TypeOf(SupportNotification{})
|
||||
for _, forbidden := range []string{"Answer", "ReferenceCount", "TopScore", "References", "Confidence"} {
|
||||
if _, ok := typ.FieldByName(forbidden); ok {
|
||||
t.Errorf("SupportNotification must not expose %s", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportNotificationEmptyValues(t *testing.T) {
|
||||
notif := SupportNotification{}
|
||||
if notif.CaseID != "" || notif.Question != "" || notif.FAQRecordID != "" {
|
||||
t.Fatalf("expected safe zero value: %+v", notif)
|
||||
}
|
||||
if len(notif.PreviousQuestions) != 0 || !notif.AskedAt.IsZero() {
|
||||
t.Fatalf("expected empty history and time: %+v", notif)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package coverage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
)
|
||||
|
||||
// Service evaluates turns and persists coverage decisions.
|
||||
type Service struct {
|
||||
evaluator *Evaluator
|
||||
repo Repository
|
||||
}
|
||||
|
||||
// NewService creates a coverage Service. repo must not be nil.
|
||||
func NewService(repo Repository) (*Service, error) {
|
||||
if repo == nil {
|
||||
return nil, errors.New("coverage service: repository must not be nil")
|
||||
}
|
||||
return &Service{
|
||||
evaluator: NewEvaluator(),
|
||||
repo: repo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ObserveSucceededTurn adapts Service to the QA succeeded-turn observer.
|
||||
// The decision is persisted durably before QA returns; callers decide how to
|
||||
// report the error without changing the successful QA result.
|
||||
func (s *Service) ObserveSucceededTurn(ctx context.Context, turn core.Turn) error {
|
||||
_, err := s.EvaluateAndPersist(ctx, turn)
|
||||
return err
|
||||
}
|
||||
|
||||
// EvaluateAndPersist evaluates a turn and persists the resulting decision.
|
||||
// For uncovered verdicts the Repository atomically creates a pending
|
||||
// support case. The operation is idempotent — replaying the same
|
||||
// (tenant_key, channel, turn_id) is safe.
|
||||
//
|
||||
// Failed and processing turns are rejected with ErrTurnNotSucceeded.
|
||||
func (s *Service) EvaluateAndPersist(ctx context.Context, turn core.Turn) (Decision, error) {
|
||||
d, err := s.evaluator.Evaluate(turn)
|
||||
if err != nil {
|
||||
return Decision{}, fmt.Errorf("evaluate: %w", err)
|
||||
}
|
||||
|
||||
if err := s.repo.SaveCoverageDecision(ctx, d); err != nil {
|
||||
return Decision{}, fmt.Errorf("save coverage decision: %w", err)
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package coverage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
)
|
||||
|
||||
// mockRepo implements Repository for testing.
|
||||
type mockRepo struct {
|
||||
saved []Decision
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockRepo) SaveCoverageDecision(_ context.Context, d Decision) error {
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
m.saved = append(m.saved, d)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNewService_RejectsNilRepo(t *testing.T) {
|
||||
_, err := NewService(nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil repo, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewService_SucceedsWithRepo(t *testing.T) {
|
||||
s, err := NewService(&mockRepo{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if s == nil {
|
||||
t.Fatal("expected non-nil service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_EvaluateAndPersist_Covered(t *testing.T) {
|
||||
repo := &mockRepo{}
|
||||
s, err := NewService(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
turn := core.Turn{
|
||||
ID: "turn-1",
|
||||
TenantKey: "t1",
|
||||
Channel: "dingtalk",
|
||||
Status: "succeeded",
|
||||
Answer: "根据知识库,答案是...",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.9},
|
||||
},
|
||||
}
|
||||
|
||||
d, err := s.EvaluateAndPersist(context.Background(), turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictCovered {
|
||||
t.Errorf("verdict = %s, want covered", d.Verdict)
|
||||
}
|
||||
if len(repo.saved) != 1 {
|
||||
t.Fatalf("expected 1 saved decision, got %d", len(repo.saved))
|
||||
}
|
||||
if repo.saved[0].Verdict != VerdictCovered {
|
||||
t.Errorf("saved verdict = %s, want covered", repo.saved[0].Verdict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_EvaluateAndPersist_UncoveredNoReferences(t *testing.T) {
|
||||
repo := &mockRepo{}
|
||||
s, _ := NewService(repo)
|
||||
|
||||
turn := core.Turn{
|
||||
ID: "turn-2",
|
||||
TenantKey: "t1",
|
||||
Channel: "dingtalk",
|
||||
Status: "succeeded",
|
||||
Answer: "正常回答但没有引用",
|
||||
}
|
||||
|
||||
d, err := s.EvaluateAndPersist(context.Background(), turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered {
|
||||
t.Errorf("verdict = %s, want uncovered", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredNoReferences {
|
||||
t.Errorf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredNoReferences)
|
||||
}
|
||||
if len(repo.saved) != 1 {
|
||||
t.Fatalf("expected 1 saved decision, got %d", len(repo.saved))
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_EvaluateAndPersist_UncoveredExplicitNoAnswer(t *testing.T) {
|
||||
repo := &mockRepo{}
|
||||
s, _ := NewService(repo)
|
||||
|
||||
turn := core.Turn{
|
||||
ID: "turn-3",
|
||||
TenantKey: "t1",
|
||||
Channel: "dingtalk",
|
||||
Status: "succeeded",
|
||||
Answer: "未找到相关结果",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.5},
|
||||
},
|
||||
}
|
||||
|
||||
d, err := s.EvaluateAndPersist(context.Background(), turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Verdict != VerdictUncovered {
|
||||
t.Errorf("verdict = %s, want uncovered", d.Verdict)
|
||||
}
|
||||
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
|
||||
t.Errorf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_EvaluateAndPersist_RejectsFailedTurn(t *testing.T) {
|
||||
repo := &mockRepo{}
|
||||
s, _ := NewService(repo)
|
||||
|
||||
turn := core.Turn{
|
||||
ID: "turn-4",
|
||||
Status: "failed",
|
||||
}
|
||||
|
||||
_, err := s.EvaluateAndPersist(context.Background(), turn)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for failed turn, got nil")
|
||||
}
|
||||
if len(repo.saved) != 0 {
|
||||
t.Errorf("expected 0 saved decisions for failed turn, got %d", len(repo.saved))
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_EvaluateAndPersist_PropagatesRepoError(t *testing.T) {
|
||||
repo := &mockRepo{err: ErrRepository}
|
||||
s, _ := NewService(repo)
|
||||
|
||||
turn := core.Turn{
|
||||
ID: "turn-5",
|
||||
Status: "succeeded",
|
||||
Answer: "正常回答",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.5},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := s.EvaluateAndPersist(context.Background(), turn)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from repo, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ObserveSucceededTurnPersistsDecision(t *testing.T) {
|
||||
repo := &mockRepo{}
|
||||
s, err := NewService(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
turn := core.Turn{
|
||||
ID: "turn-observed",
|
||||
TenantKey: "tenant",
|
||||
Channel: "dingtalk",
|
||||
Status: "succeeded",
|
||||
Answer: "正常回答",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.5},
|
||||
},
|
||||
}
|
||||
if err := s.ObserveSucceededTurn(context.Background(), turn); err != nil {
|
||||
t.Fatalf("ObserveSucceededTurn: %v", err)
|
||||
}
|
||||
if len(repo.saved) != 1 || repo.saved[0].TurnID != "turn-observed" {
|
||||
t.Fatalf("saved=%+v, want turn-observed", repo.saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_EvaluateAndPersist_DecisionHasEvaluatorVersion(t *testing.T) {
|
||||
repo := &mockRepo{}
|
||||
s, _ := NewService(repo)
|
||||
|
||||
turn := core.Turn{
|
||||
ID: "turn-6",
|
||||
TenantKey: "t1",
|
||||
Channel: "dingtalk",
|
||||
Status: "succeeded",
|
||||
Answer: "正常回答",
|
||||
References: []core.Reference{
|
||||
{ID: "r1", Score: 0.5},
|
||||
},
|
||||
}
|
||||
|
||||
d, err := s.EvaluateAndPersist(context.Background(), turn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.EvaluatorVersion != EvaluatorVersion {
|
||||
t.Errorf("evaluator_version = %s, want %s", d.EvaluatorVersion, EvaluatorVersion)
|
||||
}
|
||||
if repo.saved[0].EvaluatorVersion != EvaluatorVersion {
|
||||
t.Errorf("saved evaluator_version = %s, want %s", repo.saved[0].EvaluatorVersion, EvaluatorVersion)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user