Compare commits

..

40 Commits

Author SHA1 Message Date
姚章浩 0fc73c4152 ci: 接入提交规范闸门与自动编译流水线(本机Runner+微信通知)
build / build (push) Successful in 27s
2026-09-28 18:35:51 +08:00
姚章浩 18c81232ce fix: keep only setting command entries 2026-08-14 12:35:33 +08:00
姚章浩 19ed4b50df feat: add guided administrator management 2026-08-14 12:12:33 +08:00
姚章浩 9f65c3eef0 docs: update AI table binding example 2026-08-14 11:46:37 +08:00
姚章浩 50379b3b87 fix: parse AI table iframe query links 2026-08-14 11:40:28 +08:00
姚章浩 3f9ff359b9 feat: accept trusted AI table link tails 2026-08-14 11:20:15 +08:00
姚章浩 a3defdd1c0 fix: report AI table binding validation failures 2026-08-14 11:11:07 +08:00
姚章浩 b4b68d8b21 fix: accept AI table link cards in settings 2026-08-14 10:46:51 +08:00
姚章浩 365f1b8c7d fix: detect missing-product coverage responses 2026-08-14 10:02:58 +08:00
姚章浩 725db721a8 fix: refresh table dispatchers after binding activation 2026-08-13 18:39:15 +08:00
姚章浩 5d68b22636 feat: add operational component status dashboard 2026-08-13 18:33:52 +08:00
姚章浩 6f24743be2 feat: verify and activate support group binding 2026-08-13 18:22:43 +08:00
姚章浩 4dbc20ee74 fix: validate card business responses and trim help 2026-08-13 15:10:09 +08:00
姚章浩 e5be9ee780 fix: stop enqueueing disabled external table exports 2026-08-13 14:49:44 +08:00
姚章浩 0f30f8cec8 fix: route help and repeated-slash settings locally 2026-08-13 14:39:56 +08:00
姚章浩 44d9398c78 fix: keep setup available without active DingTalk app 2026-08-13 13:55:25 +08:00
姚章浩 b207ee4089 feat: switch DingTalk applications with rollback 2026-08-13 13:36:26 +08:00
姚章浩 0fc64ba77d feat: validate and activate AI table bindings 2026-08-13 13:14:06 +08:00
姚章浩 a7432aed49 feat: wire settings admin into DobotAI runtime 2026-08-13 12:59:20 +08:00
姚章浩 5036071ac2 feat: keep table binding messages out of QA 2026-08-13 12:55:00 +08:00
姚章浩 2d9490f424 feat: apply per-user answer language 2026-08-13 12:50:07 +08:00
姚章浩 462ee1f3ff feat: add admin settings control flow 2026-08-13 12:45:08 +08:00
姚章浩 8d2d4b469b feat: add secure settings foundation 2026-08-13 12:38:01 +08:00
姚章浩 9363d10e44 feat: publish outbound answer images to private OSS 2026-08-13 12:34:39 +08:00
姚章浩 74c9af9944 fix: acknowledge DingTalk callbacks before async processing 2026-08-13 12:34:16 +08:00
姚章浩 5c35a49486 fix: recreate stale WeKnora sessions once 2026-08-13 12:34:05 +08:00
姚章浩 357e9bad5c docs: plan DobotAI settings administration 2026-08-13 12:33:55 +08:00
姚章浩 2c0ed7cc67 feat: expose runtime build version 2026-08-13 10:44:16 +08:00
姚章浩 aa0513eb59 feat: add FAQ uncovered workflow 2026-07-27 11:54:34 +08:00
姚章浩 e582d851a4 fix: wrap Notable record fields payload 2026-07-23 18:55:03 +08:00
姚章浩 0a3f62618e feat: sync DingTalk QA to Notable question table 2026-07-23 14:38:50 +08:00
姚章浩 5e0694835a feat: add DingTalk directory-backed session titles 2026-07-23 10:55:25 +08:00
姚章浩 39c5fbbaef fix: detect retrieval gaps with human escalation 2026-07-22 14:17:39 +08:00
姚章浩 d29b28be13 fix: detect unavailable authoritative knowledge answers 2026-07-22 13:21:59 +08:00
姚章浩 774ccd9454 feat: wire coverage support notifications 2026-07-22 12:48:30 +08:00
姚章浩 86593df57c feat: add durable support notification dispatcher 2026-07-22 12:36:33 +08:00
姚章浩 913a65b16d fix: persist coverage reference count 2026-07-22 12:19:53 +08:00
姚章浩 a6569ee4bf feat: add DingTalk support group sender 2026-07-22 12:17:04 +08:00
姚章浩 5b5865dc07 feat: add coverage decision persistence 2026-07-22 12:00:32 +08:00
zheromiku 4366c018f9 Merge pull request 'feat: add exact custom card feedback reasons' (#9) from feature/custom-card-feedback-actions into main 2026-07-21 21:23:15 +08:00
153 changed files with 18485 additions and 202 deletions
+27
View File
@@ -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
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""提交信息规范校验(CI 侧权威闸门)。
规则与本机 ~/.githooks/commit-msg 完全一致,防止 --no-verify 绕过。
放行:Merge / Revert " / fixup! / squash! / amend! 开头的自动生成提交。
"""
import json
import os
import re
import subprocess
import sys
TYPES = "feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert"
RE = re.compile(r"^(?:" + TYPES + r")(?:\([a-zA-Z0-9._/-]+\))?!?: .{3,}$")
ALLOW = re.compile(r'^(?:Merge |Revert "|fixup! |squash! |amend! )')
HELP = """
\u274c 提交信息不合规,已拦截。
规范 : <类型>(<范围>): <简短描述>
类型 : feat 新功能 | fix 修缺陷 | docs 文档 | style 格式(不改逻辑)
refactor 重构 | perf 性能 | test 测试 | build 构建/依赖
ci CI 配置 | chore 杂务 | revert 回滚
示例 : feat(asset): 新增按歌曲生成应援序列
fix(parser): 修复表格解析越界导致的 500
docs: 补充 OSS 短签名接口说明
要点 : 类型小写、冒号后一个空格、描述 \u22653 字符
标题 \u226472 字符、结尾不加句号
破坏性变更类型后加 !,正文写 BREAKING CHANGE:
详见 : docs/提交规范.md
"""
def collect():
"""优先用 Gitea 事件负载里的本次推送提交,取不到则退回 git log。"""
msgs = []
ev = os.environ.get("GITHUB_EVENT_PATH") or ""
if ev and os.path.exists(ev):
try:
with open(ev, encoding="utf-8") as fh:
d = json.load(fh)
for c in (d.get("commits") or []):
m = (c.get("message") or "").strip()
if m:
msgs.append(m)
except Exception as e:
print(" 事件负载解析失败(%s),回退到 git log" % e)
if not msgs:
out = subprocess.run(["git", "log", "-n", "5", "--format=%B%x00"],
capture_output=True, text=True).stdout
msgs = [x.strip() for x in out.split("\x00") if x.strip()]
return msgs
def subject_of(m):
for line in m.splitlines():
s = line.strip()
if not s or s.startswith("#"):
continue
return s
return ""
def judge(subj):
if not subj:
return "空提交信息"
if ALLOW.match(subj):
return ""
if len(subj) > 72:
return "标题 %d 字符 > 72" % len(subj)
if not RE.match(subj):
return "不符合 <类型>(<范围>): <描述>"
if subj.endswith(".") or subj.endswith("\u3002"):
return "标题结尾不要加句号"
return ""
def main():
msgs = collect()
bad = []
print("待校验提交 %d 条:" % len(msgs))
for m in msgs:
subj = subject_of(m)
why = judge(subj)
if why:
bad.append((subj, why))
print(" \u274c [%s] %s" % (why, subj))
else:
print(" \u2705 %s" % subj)
try:
with open("/tmp/ci-gate-status", "w") as fh:
fh.write("failure" if bad else "success")
except Exception:
pass
if bad:
print(HELP)
return 1
print("\u2705 提交信息全部合规")
return 0
if __name__ == "__main__":
sys.exit(main())
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""把 CI 结果上报给主机侧 ci-bridge(bridge 负责落报告页 + 推微信)。"""
import json
import os
import subprocess
import urllib.request
def rd(path, default=""):
try:
with open(path, encoding="utf-8") as fh:
return fh.read().strip()
except Exception:
return default
def git(*args):
try:
return subprocess.run(["git"] + list(args), capture_output=True,
text=True).stdout.strip()
except Exception:
return ""
def main():
repo = os.environ.get("GITHUB_REPOSITORY", "unknown/unknown")
sha = rd("/tmp/ci-sha") or git("rev-parse", "HEAD")
branch = os.environ.get("GITHUB_REF_NAME") or \
os.environ.get("GITHUB_REF", "").replace("refs/heads/", "")
gate = rd("/tmp/ci-gate-status", "skipped")
build = rd("/tmp/ci-build-status", "failure")
status = "success" if (gate == "success" and build == "success") else "failure"
log = rd("/tmp/ci-build-tail")
if gate != "success":
log = "提交信息规范校验未通过\n" + log
try:
count = int(rd("/tmp/ci-commits-count", "1") or 1)
except Exception:
count = 1
rep = {
"repo": repo,
"sha": sha,
"branch": branch,
"actor": os.environ.get("GITHUB_ACTOR", ""),
"status": status,
"commit_subject": git("log", "-1", "--format=%s"),
"commit_count": count,
"duration_s": rd("/tmp/ci-duration", "?"),
"preview_url": rd("/tmp/ci-preview-url"),
"log": log[-3500:],
"steps": [
{"name": "提交信息规范校验", "status": gate},
{"name": "编译与自检", "status": build},
],
}
if rep["preview_url"]:
print("\U0001F310 网页预览: %s" % rep["preview_url"])
print("结果: %s | 构建: %s | 提交闸门: %s | 耗时: %ss"
% (status, build, gate, rep["duration_s"]))
base = os.environ.get("CI_BRIDGE_RESOLVED", "").rstrip("/")
if not base:
print("跳过上报:未找到可达的 ci-bridge")
return 0
data = json.dumps(rep, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
base + "/api/ci/report", data=data, method="POST",
headers={"Content-Type": "application/json",
"X-CI-Token": os.environ.get("BRIDGE_TOKEN", "")})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
print("\u2705 已上报 ci-bridge:", resp.read().decode()[:300])
except Exception as e:
print("\u274c 上报 ci-bridge 失败:", e)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+156
View File
@@ -0,0 +1,156 @@
name: build
# 提交 -> 本机 Runner 编译 -> 微信通知 + 网页预览
# 实测结论(勿随意改动):
# 1. 不用任何第三方 action(作业容器不联网拉 action,全用镜像自带工具)
# 2. 必须自己 clone —— Runner 不会自动检出代码,工作区初始为空
# 3. act 以 `bash -e -o pipefail` 执行每步 -> 禁止 `cmd | head` 这种会 SIGPIPE 提前退出的管道
# 4. 工具链来自主机只读挂载 /opt/citools(go/uv),缓存写 /opt/citools-cache
# 5. bridge 从容器内走 docker 网关 172.17.0.1:8899(实测可达)
# 6. 校验/上报逻辑放在 .gitea/scripts/ 下的脚本里,YAML 只负责调用(避免 YAML 里嵌深缩进代码)
on:
push:
branches:
- "**"
workflow_dispatch:
env:
BRIDGE_URL: ${{ vars.CI_BRIDGE_URL }}
BRIDGE_TOKEN: ${{ secrets.CI_BRIDGE_TOKEN }}
GIT_TOKEN: ${{ secrets.GITHUB_TOKEN }}
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: 检出代码
run: |
set -uo pipefail
SRV="${GITHUB_SERVER_URL:-http://192.168.50.223:3000}"
HP="${SRV#*://}"
BR="${GITHUB_REF_NAME:-${GITHUB_REF#refs/heads/}}"
cd "${GITHUB_WORKSPACE:-$(pwd)}"
rm -rf ./* .git 2>/dev/null || true
git init -q .
git remote add origin "http://x-access-token:${GIT_TOKEN}@${HP}/${GITHUB_REPOSITORY}.git"
git fetch -q --depth 50 origin "$BR"
git checkout -q -B "$BR" FETCH_HEAD
git rev-parse HEAD > /tmp/ci-sha
date +%s > /tmp/ci-start
: > /tmp/ci-preview-url
echo skipped > /tmp/ci-build-status
git rev-list --count FETCH_HEAD > /tmp/ci-commits-count 2>/dev/null || echo 1 > /tmp/ci-commits-count
echo "已检出 ${GITHUB_REPOSITORY} 分支 ${BR}"
git log -1 --format=' %h %an %s'
- name: 提交信息规范校验
run: python3 .gitea/scripts/ci_gate.py
- name: 编译与自检
run: |
set +e; set +o pipefail
LOG=/tmp/ci-build.log; : > "$LOG"
run() { echo "-- $* --"; "$@" 2>&1 | tee -a "$LOG"; return "${PIPESTATUS[0]}"; }
runin(){ local d="$1"; shift; echo "-- ($d) $* --"; ( cd "$d" && "$@" ) 2>&1 | tee -a "$LOG"; return "${PIPESTATUS[0]}"; }
export PATH=/opt/citools/go/bin:/opt/citools/bin:$PATH
export GOCACHE=/opt/citools-cache/gocache GOMODCACHE=/opt/citools-cache/gomod UV_CACHE_DIR=/opt/citools-cache/uv
export GOPROXY=https://goproxy.cn,direct GOTOOLCHAIN=local GOFLAGS=-mod=mod GOPATH=/opt/citools-cache/gopath
mkdir -p "$GOCACHE" "$GOMODCACHE" "$UV_CACHE_DIR" "$GOPATH" 2>/dev/null
echo "工具链: $(go version 2>&1) | $(uv --version 2>&1) | $(node -v 2>&1) | $(python3 -V 2>&1)"
fail=0; ran=""
if [ -f go.mod ]; then
ran="$ran go"
run go build ./... || fail=1
run go vet ./... || fail=1
run go test ./... || fail=1
fi
if [ -f pyproject.toml ] || [ -f requirements.txt ] || [ -f setup.py ]; then
ran="$ran python"
if grep -qE '^check:' Makefile 2>/dev/null; then
run make UV=uv check || fail=1
else
run uv sync || fail=1
run uv run pytest -q || fail=1
fi
fi
WEBPKG=""
for p in package.json web/package.json frontend/package.json ui/package.json; do
if [ -f "$p" ]; then WEBPKG="$p"; break; fi
done
if [ -n "$WEBPKG" ]; then
ran="$ran node"
D="$(dirname "$WEBPKG")"
if [ -f "$D/package-lock.json" ]; then
runin "$D" npm ci || fail=1
else
runin "$D" npm install || fail=1
fi
if grep -q '"build"[[:space:]]*:' "$WEBPKG" 2>/dev/null; then
runin "$D" npm run build || fail=1
fi
fi
if [ -z "$ran" ] && [ -f Makefile ]; then
ran="$ran make"
run make || fail=1
fi
if [ -z "$ran" ]; then
echo "未识别到构建入口(go.mod / pyproject.toml / package.json / Makefile 都没有),跳过编译"
fi
if [ "$fail" -eq 0 ]; then echo success > /tmp/ci-build-status; else echo failure > /tmp/ci-build-status; fi
tail -c 4000 "$LOG" > /tmp/ci-build-tail
echo "构建结果: $(cat /tmp/ci-build-status)(识别:$ran)"
[ "$fail" -eq 0 ] || exit 1
- name: 发布网页预览
if: always()
run: |
set +e; set +o pipefail
: > /tmp/ci-preview-url
if [ "$(cat /tmp/ci-build-status 2>/dev/null)" != "success" ]; then
echo "构建未成功,跳过预览发布"; exit 0
fi
WEB=""
for d in web/dist dist build out public web/build frontend/dist; do
if [ -f "$d/index.html" ]; then WEB="$d"; break; fi
done
if [ -z "$WEB" ]; then echo "未发现静态网页产物目录,跳过预览发布"; exit 0; fi
tar -czf /tmp/ci-web.tgz -C "$WEB" . || { echo "打包失败"; exit 0; }
B="${BRIDGE_URL:-}"
for cand in "$B" "http://172.17.0.1:8899" "http://192.168.50.134:8899" "http://100.92.49.78:8899"; do
if [ -n "$cand" ]; then
curl -s -m 5 -o /dev/null "$cand/healthz" && { B="$cand"; break; }
fi
done
SHA="$(cat /tmp/ci-sha)"
echo "发布预览到 $B(产物目录 $WEB)"
curl -sS -m 90 -X POST "$B/api/ci/publish?repo=${GITHUB_REPOSITORY}&sha=${SHA}" -H "X-CI-Token: ${BRIDGE_TOKEN}" -H 'Content-Type: application/gzip' --data-binary @/tmp/ci-web.tgz -o /tmp/ci-pub.json
cat /tmp/ci-pub.json; echo
URL="$(python3 -c "import json;print(json.load(open('/tmp/ci-pub.json')).get('url',''))" 2>/dev/null)"
if [ -n "$URL" ]; then echo "$URL" > /tmp/ci-preview-url; echo "网页预览: $URL"; fi
- name: 通知微信
if: always()
run: |
set +e; set +o pipefail
B="${BRIDGE_URL:-}"
for cand in "$B" "http://172.17.0.1:8899" "http://192.168.50.134:8899" "http://100.92.49.78:8899"; do
if [ -n "$cand" ]; then
curl -s -m 5 -o /dev/null "$cand/healthz" && { B="$cand"; break; }
fi
done
echo "使用 ci-bridge: $B"
S=$(date +%s); T=$(cat /tmp/ci-start 2>/dev/null); [ -n "$T" ] || T=$S
echo $((S - T)) > /tmp/ci-duration
CI_BRIDGE_RESOLVED="$B" python3 .gitea/scripts/ci_report.py
+5
View File
@@ -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
+39
View File
@@ -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
+85
View File
@@ -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
}
+48
View File
@@ -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) }
}
}
+81
View File
@@ -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
}
+75
View File
@@ -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")
}
}
+27
View File
@@ -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")
}
}
+92
View File
@@ -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()
}
}
}
+81
View File
@@ -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)
}
}
}
+251 -8
View File
@@ -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,169 @@ 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")
}
// 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)
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)
}
outboundRewriter, err := buildOutboundImageRewriter(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"),
})
if err != nil {
log.Fatalf("DingTalk outbound image init failed: %v", err)
}
feedbackService, err := feedback.NewService(dingCfg.TenantKey, "dingtalk", store)
if err != nil {
log.Fatal(err)
@@ -112,7 +282,14 @@ func main() {
dingtalk.TenantConfig{TenantKey: dingCfg.TenantKey, Channel: "dingtalk"},
qaService,
sender,
dingtalk.HandlerOptions{ImageDownloader: downloader, CardSender: cardSender},
dingtalk.HandlerOptions{
ImageDownloader: downloader,
CardSender: cardSender,
Directory: directoryResolver,
OutboundRewriter: outboundRewriter,
SettingsRouter: &dingTalkSettingsRouter{controller: settingsRuntime.controller},
LanguageResolver: settingsRuntime.repository,
},
)
if err != nil {
log.Fatal(err)
@@ -125,9 +302,42 @@ 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 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 +351,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",
} {
if strings.TrimSpace(os.Getenv(key)) == "" {
return false
}
}
return true
}
func buildAuditOptions(repo audit.TurnRepository, tenantKey, adminToken, address string) (httpapi.Options, error) {
+43
View File
@@ -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,32 @@ func TestMainWiresOptionalDingTalkCardDebugUser(t *testing.T) {
}
}
}
func TestBuildOutboundImageRewriterConfig(t *testing.T) {
t.Run("disabled", func(t *testing.T) {
rewriter, err := buildOutboundImageRewriter(outboundImageConfig{})
if err != nil || rewriter != nil {
t.Fatalf("rewriter=%v err=%v", rewriter, err)
}
})
t.Run("partial configuration rejected", func(t *testing.T) {
_, err := buildOutboundImageRewriter(outboundImageConfig{Bucket: "private-bucket"})
if err == nil {
t.Fatal("expected configuration error")
}
})
t.Run("complete configuration", func(t *testing.T) {
rewriter, err := buildOutboundImageRewriter(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",
})
if err != nil || rewriter == nil {
t.Fatalf("rewriter=%v err=%v", rewriter, err)
}
})
}
+164
View File
@@ -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
}
+105
View File
@@ -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)
}
}
+70
View File
@@ -0,0 +1,70 @@
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
outboundImageURLTTL = 2 * time.Hour
outboundImagePrefix = "dobotai/im-images"
)
type outboundImageConfig struct {
WeKnoraBaseURL string
WeKnoraAPIKey string
Endpoint string
Region string
AccessKeyID string
AccessKeySecret string
Bucket string
}
func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundContentRewriter, error) {
ossValues := []string{cfg.Endpoint, cfg.Region, cfg.AccessKeyID, cfg.AccessKeySecret, cfg.Bucket}
enabled := false
for _, value := range ossValues {
if strings.TrimSpace(value) != "" {
enabled = true
break
}
}
if !enabled {
return nil, nil
}
for _, value := range append([]string{cfg.WeKnoraBaseURL, cfg.WeKnoraAPIKey}, ossValues...) {
if strings.TrimSpace(value) == "" {
return nil, errors.New("outbound image: complete WeKnora and OSS 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 nil, err
}
store, err := outboundimage.NewAliyunOSSStore(
cfg.Endpoint,
cfg.Region,
cfg.AccessKeyID,
cfg.AccessKeySecret,
cfg.Bucket,
)
if err != nil {
return nil, err
}
publisher := outboundimage.NewHashPublisher(store, outboundImagePrefix, outboundImageURLTTL)
return outboundimage.NewRewriter(fetcher, publisher, outboundImageMaxCount), nil
}
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"context"
"errors"
"log"
"os"
"reflect"
"strings"
"time"
"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.
// No HTTP request occurs at build time. It returns (nil, nil) when the
// question-table feature is disabled (empty config).
func buildQuestionTableDispatcher(repo questiontable.ExportRepository, clientID, clientSecret string, cfg questionTableConfig) (*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, 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
}
}
+377
View File
@@ -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{})
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)
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)
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)
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)
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
}
+133
View File
@@ -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")
}
}
})
}
}
+98
View File
@@ -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
}
+65
View File
@@ -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)
}
}
+102
View File
@@ -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")
}
}
+67
View File
@@ -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()
}
}
}
+179
View File
@@ -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,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)
}
}
}
}
+11
View File
@@ -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;
+24
View File
@@ -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)
}
}
}
+1
View File
@@ -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.
+2
View File
@@ -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
)
+4
View File
@@ -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=
+205
View File
@@ -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)
}
+210
View File
@@ -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)
}
}
+253
View File
@@ -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
}
+166
View File
@@ -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())
}
}
+24 -5
View File
@@ -305,9 +305,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 +342,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) {
+16 -1
View File
@@ -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()
-4
View File
@@ -79,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{
+4 -2
View File
@@ -84,7 +84,9 @@ func TestCardFeedbackMapsNativeDislike(t *testing.T) {
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"] != "true" {
if response == nil || response.UserPrivateData == nil ||
response.UserPrivateData.CardParamMap["showFeedbackReasons"] != "false" ||
response.UserPrivateData.CardParamMap["feedbackMessage"] != "感谢反馈,我们会继续改进" {
t.Fatalf("response=%+v", response)
}
}
@@ -201,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", "感谢反馈,我们会继续改进"},
+360
View File
@@ -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 = &copyClient
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)), &params); 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)), &params); 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)
}
})
}
}
+190 -23
View File
@@ -9,6 +9,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 +33,18 @@ 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
}
type QAAsker interface {
@@ -73,6 +75,35 @@ type ImageDownloader interface {
Download(ctx context.Context, downloadCode string) (core.ImageInput, 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 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 +118,12 @@ type StreamingCardSender interface {
}
type HandlerOptions struct {
ImageDownloader ImageDownloader
CardSender FinalCardSender
ImageDownloader ImageDownloader
CardSender FinalCardSender
Directory UserDirectory
OutboundRewriter OutboundContentRewriter
SettingsRouter SettingsRouter
LanguageResolver LanguagePreferenceResolver
}
type Handler struct {
@@ -97,6 +132,10 @@ type Handler struct {
reply MarkdownSender
downloader ImageDownloader
card FinalCardSender
directory UserDirectory
outboundRewriter OutboundContentRewriter
settingsRouter SettingsRouter
languageResolver LanguagePreferenceResolver
now func() time.Time
cardUpdateInterval time.Duration
finalDeliveryTimeout time.Duration
@@ -137,6 +176,10 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o
reply: reply,
downloader: opts.ImageDownloader,
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 +213,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 +277,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)) {
@@ -229,6 +320,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 +354,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 +363,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 +378,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 +399,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 +408,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
}
@@ -335,6 +437,7 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
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 +455,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
}
@@ -387,6 +492,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 +504,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 +536,19 @@ 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 content
}
timeout := h.finalDeliveryTimeout
if timeout <= 0 {
timeout = defaultHTTPTimeout
}
rewriteCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
defer cancel()
return h.outboundRewriter.Rewrite(rewriteCtx, 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 +600,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 {
+298 -3
View File
@@ -8,6 +8,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"
)
@@ -31,6 +32,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 +204,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 +726,46 @@ func TestHandleCardSuccessDirectFieldsCorrectNoMarkdown(t *testing.T) {
}
}
func TestHandleNonStreamingRewritesFinalCardAndMarkdownFallback(t *testing.T) {
resource := "resource://AbCdEfGhIjKlMnOpQrStUv"
signed := "https://img.example/signed.jpg"
answer := "说明\n![图](" + resource + ")"
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 +980,17 @@ type cardUpdate struct {
isError bool
}
type fakeOutboundRewriter struct {
calls 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 *fakeStreamingCard) SendFinalCard(_ context.Context, req CardRequest) (string, error) {
f.finalCalled = true
f.finalReq = req
@@ -1073,11 +1174,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![图](" + resource + ")"},
}
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 +1210,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
@@ -1796,3 +1903,191 @@ 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![步骤图](" + resource + ")"
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 should remain untouched: %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)
}
}
@@ -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")
}
}
+154 -26
View File
@@ -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,16 +73,17 @@ 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)
@@ -87,12 +92,85 @@ func BotCallbackFromSDK(data *chatbot.BotCallbackDataModel) (BotCallback, error)
cb.DownloadCode = rtCode
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" {
cb.DownloadCode = extractDownloadCode(data.Content)
}
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": [...]}
@@ -221,18 +299,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 +392,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)
+147
View File
@@ -1,12 +1,79 @@
package dingtalk
import (
"context"
"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 {
@@ -142,6 +209,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()
@@ -542,6 +649,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)
+50 -35
View File
@@ -37,16 +37,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 +83,42 @@ 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
Status string
ErrorMessage string
}
// Delivery status constants for outbound messages.
@@ -143,6 +157,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
}
+63
View File
@@ -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
}
+427
View File
@@ -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)
}
}
+139
View File
@@ -0,0 +1,139 @@
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. Zero references (nil or empty) → uncovered_no_references
// 4. 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
}
// 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
}
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
}
+415
View File
@@ -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)
}
}
+144
View File
@@ -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
}
}
+59
View File
@@ -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)
}
}
+53
View File
@@ -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
}
+214
View File
@@ -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)
}
}
+60
View File
@@ -0,0 +1,60 @@
// Package coverage implements the domain logic for evaluating whether a QA
// turn is covered by the knowledge base and persisting the resulting decision.
//
// The Evaluator classifies a successful turn into one of three outcomes:
// - covered_has_references: the answer uses knowledge-base references
// - uncovered_no_references: the answer has no references
// - uncovered_explicit_no_answer: the answer is an explicit "no answer" phrase
//
// The Service delegates persistence to a Repository. For uncovered turns it
// atomically writes the decision and creates a pending support case. For
// covered turns it only persists the decision. All writes are idempotent via
// ON CONFLICT (tenant_key, channel, turn_id).
package coverage
import (
"context"
"errors"
)
// Verdict values.
const (
VerdictCovered = "covered"
VerdictUncovered = "uncovered"
)
// ReasonCode values.
const (
ReasonCoveredHasReferences = "covered_has_references"
ReasonUncoveredNoReferences = "uncovered_no_references"
ReasonUncoveredExplicitNoAnswer = "uncovered_explicit_no_answer"
)
// EvaluatorVersion is the current version of the evaluator.
const EvaluatorVersion = "v3"
var (
ErrTurnNotSucceeded = errors.New("coverage evaluator: turn status must be succeeded")
ErrRepository = errors.New("coverage repository error")
)
// Decision is the result of evaluating a single turn.
type Decision struct {
TurnID string
TenantKey string
Channel string
EvaluatorVersion string
Verdict string
ReasonCode string
ReferenceCount int
TopScore float64
}
// Repository persists coverage decisions and, when applicable, support cases.
type Repository interface {
// SaveCoverageDecision persists a decision. For uncovered verdicts it
// must also atomically create a pending support case. The call is
// idempotent — replayed decisions for the same (tenant_key, channel,
// turn_id) must not create duplicates.
SaveCoverageDecision(ctx context.Context, d Decision) error
}
+69
View File
@@ -0,0 +1,69 @@
package dingtalkconfig
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Result struct {
OK bool
Code string
CheckedAt time.Time
}
type Verifier struct {
endpoint string
client *http.Client
}
func NewVerifier(endpoint string, client *http.Client) *Verifier {
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
return &Verifier{endpoint: strings.TrimSpace(endpoint), client: client}
}
func (v *Verifier) Verify(ctx context.Context, id, secret string) Result {
r := Result{Code: "verification_failed", CheckedAt: time.Now().UTC()}
if strings.TrimSpace(id) == "" || strings.TrimSpace(secret) == "" {
r.Code = "credential_incomplete"
return r
}
u, err := url.Parse(v.endpoint)
if err != nil {
r.Code = "endpoint_invalid"
return r
}
q := u.Query()
q.Set("appkey", id)
q.Set("appsecret", secret)
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return r
}
resp, err := v.client.Do(req)
if err != nil {
r.Code = "network_error"
return r
}
defer resp.Body.Close()
var body struct {
ErrCode int `json:"errcode"`
AccessToken string `json:"access_token"`
}
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body) != nil {
r.Code = "invalid_response"
return r
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || body.ErrCode != 0 || strings.TrimSpace(body.AccessToken) == "" {
r.Code = "credential_rejected"
return r
}
r.OK = true
r.Code = "ok"
return r
}
+35
View File
@@ -0,0 +1,35 @@
package dingtalkconfig
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestVerifierNeverReturnsToken(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"errcode":0,"access_token":"secret-token"}`))
}))
defer ts.Close()
v := NewVerifier(ts.URL, ts.Client())
result := v.Verify(context.Background(), "id", "secret")
if !result.OK {
t.Fatalf("result=%+v", result)
}
if strings.Contains(result.Code, "secret-token") {
t.Fatal("token leaked")
}
}
func TestVerifierRejectsBadCredential(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"errcode":40014,"errmsg":"bad secret"}`))
}))
defer ts.Close()
result := NewVerifier(ts.URL, ts.Client()).Verify(context.Background(), "id", "wrong")
if result.OK || result.Code != "credential_rejected" {
t.Fatalf("result=%+v", result)
}
}
+121
View File
@@ -0,0 +1,121 @@
package directory
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
type DingTalkLookupConfig struct {
TokenURL string
APIBaseURL string
ClientID string
ClientSecret string
Timeout time.Duration
}
type DingTalkLookup struct {
tokenURL string
apiBaseURL string
clientID string
clientSecret string
httpClient *http.Client
mu sync.Mutex
token string
tokenExpiry time.Time
now func() time.Time
}
func NewDingTalkLookup(cfg DingTalkLookupConfig) (*DingTalkLookup, error) {
cfg.TokenURL = strings.TrimSpace(cfg.TokenURL)
cfg.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.APIBaseURL), "/")
if cfg.TokenURL == "" || cfg.APIBaseURL == "" || strings.TrimSpace(cfg.ClientID) == "" || strings.TrimSpace(cfg.ClientSecret) == "" {
return nil, errors.New("dingtalk lookup endpoints and credentials are required")
}
if _, err := url.ParseRequestURI(cfg.TokenURL); err != nil { return nil, errors.New("invalid dingtalk token endpoint") }
if _, err := url.ParseRequestURI(cfg.APIBaseURL); err != nil { return nil, errors.New("invalid dingtalk API endpoint") }
if cfg.Timeout <= 0 { cfg.Timeout = 10 * time.Second }
return &DingTalkLookup{
tokenURL: cfg.TokenURL, apiBaseURL: cfg.APIBaseURL,
clientID: cfg.ClientID, clientSecret: cfg.ClientSecret,
httpClient: &http.Client{Timeout: cfg.Timeout}, now: time.Now,
}, nil
}
func (c *DingTalkLookup) LookupUser(ctx context.Context, staffID string) (Profile, error) {
staffID = strings.TrimSpace(staffID)
if staffID == "" { return Profile{}, errors.New("dingtalk user id is required") }
token, err := c.accessToken(ctx)
if err != nil { return Profile{}, err }
payload, _ := json.Marshal(map[string]string{"userid": staffID})
endpoint := c.apiBaseURL + "/topapi/v2/user/get?access_token=" + url.QueryEscape(token)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil { return Profile{}, errors.New("dingtalk user request failed") }
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil { return Profile{}, errors.New("dingtalk user request failed") }
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { return Profile{}, errors.New("dingtalk user HTTP failure") }
var decoded struct {
ErrCode int `json:"errcode"`
Result struct {
UserID string `json:"userid"`
UnionID string `json:"unionid"`
Name string `json:"name"`
Nickname string `json:"nickname"`
Title string `json:"title"`
DepartmentIDs []int64 `json:"dept_id_list"`
Status int `json:"status"`
} `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil || decoded.ErrCode != 0 {
return Profile{}, errors.New("dingtalk user response invalid")
}
ids := make([]string, 0, len(decoded.Result.DepartmentIDs))
for _, id := range decoded.Result.DepartmentIDs { ids = append(ids, strconv.FormatInt(id, 10)) }
return Profile{
StaffID: staffID, UnionID: decoded.Result.UnionID, Name: strings.TrimSpace(decoded.Result.Name),
Nickname: decoded.Result.Nickname, JobTitle: decoded.Result.Title, DepartmentIDs: ids,
EmploymentStatus: strconv.Itoa(decoded.Result.Status), Source: SourceOnDemand, SyncedAt: c.now().UTC(),
}, nil
}
func (c *DingTalkLookup) accessToken(ctx context.Context) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.token != "" && c.now().Before(c.tokenExpiry) { return c.token, nil }
parsed, err := url.Parse(c.tokenURL)
if err != nil { return "", errors.New("dingtalk token request failed") }
query := parsed.Query()
query.Set("appkey", c.clientID)
query.Set("appsecret", c.clientSecret)
parsed.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
if err != nil { return "", errors.New("dingtalk token request failed") }
resp, err := c.httpClient.Do(req)
if err != nil { return "", errors.New("dingtalk token request failed") }
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", errors.New("dingtalk token HTTP failure") }
var decoded struct {
ErrCode int `json:"errcode"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil || decoded.ErrCode != 0 || decoded.AccessToken == "" {
return "", errors.New("dingtalk token response invalid")
}
ttl := time.Duration(decoded.ExpiresIn) * time.Second
if ttl <= 0 { ttl = time.Hour }
refreshMargin := 5 * time.Minute
if ttl <= refreshMargin { refreshMargin = ttl / 2 }
c.token = decoded.AccessToken
c.tokenExpiry = c.now().Add(ttl - refreshMargin)
return c.token, nil
}
@@ -0,0 +1,81 @@
package directory
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestDingTalkLookupFetchesUserAndCachesToken(t *testing.T) {
var tokenCalls atomic.Int32
var userCalls atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/gettoken", func(w http.ResponseWriter, r *http.Request) {
tokenCalls.Add(1)
if r.Method != http.MethodGet || r.URL.Query().Get("appkey") != "app-key" || r.URL.Query().Get("appsecret") != "app-secret" {
t.Fatalf("unexpected token request: %s %s", r.Method, r.URL.String())
}
_, _ = w.Write([]byte(`{"errcode":0,"access_token":"token-secret","expires_in":7200}`))
})
mux.HandleFunc("/topapi/v2/user/get", func(w http.ResponseWriter, r *http.Request) {
userCalls.Add(1)
if r.Method != http.MethodPost || r.URL.Query().Get("access_token") != "token-secret" {
t.Fatalf("unexpected user request: %s %s", r.Method, r.URL.String())
}
var body [256]byte
n, _ := r.Body.Read(body[:])
if !strings.Contains(string(body[:n]), `"userid":"staff-1"`) {
t.Fatalf("unexpected body: %s", body[:n])
}
_, _ = w.Write([]byte(`{"errcode":0,"result":{"userid":"staff-1","unionid":"union-1","name":"张三","nickname":"小张","title":"工程师","dept_id_list":[100,200],"status":5}}`))
})
server := httptest.NewServer(mux)
defer server.Close()
lookup, err := NewDingTalkLookup(DingTalkLookupConfig{
TokenURL: server.URL + "/gettoken", APIBaseURL: server.URL,
ClientID: "app-key", ClientSecret: "app-secret", Timeout: 2 * time.Second,
})
if err != nil { t.Fatal(err) }
for i := 0; i < 2; i++ {
profile, err := lookup.LookupUser(context.Background(), "staff-1")
if err != nil { t.Fatalf("LookupUser(%d): %v", i, err) }
if profile.Name != "张三" || profile.UnionID != "union-1" || profile.JobTitle != "工程师" || len(profile.DepartmentIDs) != 2 || profile.DepartmentIDs[1] != "200" {
t.Fatalf("profile=%+v", profile)
}
}
if tokenCalls.Load() != 1 || userCalls.Load() != 2 {
t.Fatalf("token calls=%d user calls=%d", tokenCalls.Load(), userCalls.Load())
}
}
func TestDingTalkLookupErrorsAreSanitized(t *testing.T) {
for _, failPath := range []string{"token", "user"} {
t.Run(failPath, func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/gettoken", func(w http.ResponseWriter, _ *http.Request) {
if failPath == "token" { http.Error(w, "raw-secret-body", 500); return }
_, _ = w.Write([]byte(`{"errcode":0,"access_token":"token-secret","expires_in":7200}`))
})
mux.HandleFunc("/topapi/v2/user/get", func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "raw-secret-body", 500)
})
server := httptest.NewServer(mux)
defer server.Close()
lookup, err := NewDingTalkLookup(DingTalkLookupConfig{TokenURL: server.URL + "/gettoken", APIBaseURL: server.URL, ClientID: "app-key", ClientSecret: "app-secret", Timeout: time.Second})
if err != nil { t.Fatal(err) }
_, err = lookup.LookupUser(context.Background(), "staff-secret")
if err == nil { t.Fatal("expected error") }
for _, forbidden := range []string{"raw-secret-body", "app-secret", "token-secret", "staff-secret"} {
if strings.Contains(err.Error(), forbidden) { t.Fatalf("error leaked %q: %v", forbidden, err) }
}
if !strings.Contains(err.Error(), fmt.Sprintf("dingtalk %s", failPath)) {
t.Fatalf("error=%q", err)
}
})
}
}
+105
View File
@@ -0,0 +1,105 @@
package directory
import (
"context"
"encoding/csv"
"errors"
"fmt"
"io"
"strings"
"time"
)
var requiredCSVHeaders = []string{"user_id", "name", "dept_name"}
// ImportCSV validates the complete snapshot before making one repository call.
// It consumes the tested Python sync output and accepts optional production
// enrichment columns without reimplementing the bulk DingTalk crawler in Go.
func ImportCSV(ctx context.Context, tenantKey string, input io.Reader, writer SnapshotWriter) (int, error) {
tenantKey = strings.TrimSpace(tenantKey)
if tenantKey == "" || input == nil || writer == nil {
return 0, errors.New("tenant, CSV input and writer are required")
}
r := csv.NewReader(input)
r.FieldsPerRecord = -1
header, err := r.Read()
if err != nil {
return 0, fmt.Errorf("read directory CSV header: %w", err)
}
columns := make(map[string]int, len(header))
for i, name := range header {
name = strings.TrimSpace(strings.TrimPrefix(name, "\ufeff"))
columns[name] = i
}
for _, name := range requiredCSVHeaders {
if _, ok := columns[name]; !ok {
return 0, fmt.Errorf("directory CSV missing required header %q", name)
}
}
profiles := make([]Profile, 0, 256)
line := 1
for {
record, readErr := r.Read()
if errors.Is(readErr, io.EOF) {
break
}
line++
if readErr != nil {
return 0, fmt.Errorf("read directory CSV line %d: %w", line, readErr)
}
value := func(name string) string {
idx, ok := columns[name]
if !ok || idx >= len(record) {
return ""
}
return strings.TrimSpace(record[idx])
}
staffID := value("user_id")
name := value("name")
if staffID == "" || name == "" {
return 0, fmt.Errorf("directory CSV line %d has empty user_id or name", line)
}
profiles = append(profiles, Profile{
StaffID: staffID,
UnionID: value("union_id"),
Name: name,
Nickname: value("nickname"),
JobTitle: value("title"),
DepartmentIDs: splitDepartmentIDs(value("department_ids")),
DepartmentName: value("dept_name"),
MainDepartmentName: value("main_department_name"),
Region: value("region"),
EmploymentStatus: value("employment_status"),
Source: SourceFullSync,
SyncedAt: parseSyncTime(value("sync_time")),
})
}
if len(profiles) == 0 {
return 0, errors.New("directory CSV snapshot is empty")
}
if err := writer.UpsertDirectoryProfiles(ctx, tenantKey, profiles); err != nil {
return 0, fmt.Errorf("write directory snapshot: %w", err)
}
return len(profiles), nil
}
func splitDepartmentIDs(raw string) []string {
parts := strings.FieldsFunc(raw, func(r rune) bool { return r == '/' || r == ',' || r == ';' })
out := make([]string, 0, len(parts))
for _, part := range parts {
if part = strings.TrimSpace(part); part != "" {
out = append(out, part)
}
}
return out
}
func parseSyncTime(raw string) time.Time {
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05"} {
if parsed, err := time.Parse(layout, raw); err == nil {
return parsed
}
}
return time.Time{}
}
+87
View File
@@ -0,0 +1,87 @@
package directory
import (
"context"
"errors"
"strings"
"testing"
)
type recordingWriter struct {
calls int
tenant string
profiles []Profile
err error
}
func (w *recordingWriter) UpsertDirectoryProfiles(_ context.Context, tenant string, profiles []Profile) error {
w.calls++
w.tenant = tenant
w.profiles = append([]Profile(nil), profiles...)
return w.err
}
func TestImportCSVAcceptsTestedSyncOutputAndExtendedFields(t *testing.T) {
csv := "\ufeffuser_id,union_id,name,nickname,title,department_ids,dept_name,main_department_name,region,employment_status,sync_time,mobile\n" +
"staff-1,union-1, 张三 ,小张,工程师,100/200,公司/技术支持部,技术支持部,APAC,on_job,2026-07-22 02:30:00,13800000000\n"
writer := &recordingWriter{}
count, err := ImportCSV(context.Background(), "tenant-a", strings.NewReader(csv), writer)
if err != nil {
t.Fatalf("ImportCSV: %v", err)
}
if count != 1 || writer.calls != 1 || writer.tenant != "tenant-a" || len(writer.profiles) != 1 {
t.Fatalf("count=%d calls=%d tenant=%q profiles=%d", count, writer.calls, writer.tenant, len(writer.profiles))
}
p := writer.profiles[0]
if p.StaffID != "staff-1" || p.Name != "张三" || p.DepartmentName != "公司/技术支持部" || p.MainDepartmentName != "技术支持部" || p.Region != "APAC" || p.EmploymentStatus != "on_job" || p.Source != SourceFullSync {
t.Fatalf("profile mismatch: %+v", p)
}
if len(p.DepartmentIDs) != 2 || p.DepartmentIDs[0] != "100" || p.DepartmentIDs[1] != "200" {
t.Fatalf("department ids: %v", p.DepartmentIDs)
}
// The domain type intentionally has no mobile/email/raw payload fields.
}
func TestImportCSVAcceptsCurrentSevenColumnOutput(t *testing.T) {
csv := "user_id,union_id,name,nickname,title,department_ids,dept_name\n" +
"staff-2,union-2,李四,,经理,300,公司/销售部\n"
writer := &recordingWriter{}
count, err := ImportCSV(context.Background(), "tenant-a", strings.NewReader(csv), writer)
if err != nil {
t.Fatalf("ImportCSV: %v", err)
}
if count != 1 || writer.profiles[0].Name != "李四" || writer.profiles[0].Region != "" {
t.Fatalf("count=%d profile=%+v", count, writer.profiles[0])
}
}
func TestImportCSVRejectsInvalidSnapshotBeforeWrite(t *testing.T) {
tests := []struct {
name string
csv string
}{
{name: "missing required header", csv: "user_id,dept_name\nstaff-1,技术支持部\n"},
{name: "empty snapshot", csv: "user_id,name,dept_name\n"},
{name: "empty staff id", csv: "user_id,name,dept_name\n,张三,技术支持部\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
writer := &recordingWriter{}
_, err := ImportCSV(context.Background(), "tenant-a", strings.NewReader(tt.csv), writer)
if err == nil {
t.Fatal("expected error")
}
if writer.calls != 0 {
t.Fatalf("writer called %d times before validation", writer.calls)
}
})
}
}
func TestImportCSVPropagatesWriterError(t *testing.T) {
writer := &recordingWriter{err: errors.New("db unavailable")}
_, err := ImportCSV(context.Background(), "tenant-a", strings.NewReader("user_id,name,dept_name\nstaff-1,张三,技术支持部\n"), writer)
if err == nil || writer.calls != 1 {
t.Fatalf("error=%v calls=%d", err, writer.calls)
}
}
+32
View File
@@ -0,0 +1,32 @@
package directory
import (
"context"
"time"
)
const (
SourceFullSync = "full_sync"
SourceOnDemand = "on_demand"
)
// Profile is the minimum non-sensitive DingTalk directory record used by
// DobotAI. It deliberately excludes mobile, email, credentials and raw payloads.
type Profile struct {
StaffID string
UnionID string
Name string
Nickname string
JobTitle string
DepartmentIDs []string
DepartmentName string
MainDepartmentName string
Region string
EmploymentStatus string
Source string
SyncedAt time.Time
}
type SnapshotWriter interface {
UpsertDirectoryProfiles(ctx context.Context, tenantKey string, profiles []Profile) error
}
+65
View File
@@ -0,0 +1,65 @@
package directory
import (
"context"
"errors"
"strings"
"time"
)
type ProfileStore interface {
LookupDirectoryProfile(ctx context.Context, tenantKey, staffID string) (Profile, bool, error)
UpsertDirectoryProfiles(ctx context.Context, tenantKey string, profiles []Profile) error
}
type UserLookup interface {
LookupUser(ctx context.Context, staffID string) (Profile, error)
}
type Resolver struct {
store ProfileStore
lookup UserLookup
now func() time.Time
}
func NewResolver(store ProfileStore, lookup UserLookup) (*Resolver, error) {
if store == nil {
return nil, errors.New("directory profile store is required")
}
return &Resolver{store: store, lookup: lookup, now: time.Now}, nil
}
// ResolveUser always returns a safe callback-based fallback profile when the
// cache or on-demand lookup is unavailable. The diagnostic error is intentionally
// generic so identifiers and upstream payloads cannot leak into logs.
func (r *Resolver) ResolveUser(ctx context.Context, tenantKey, staffID, fallbackName string) (Profile, error) {
fallback := Profile{StaffID: strings.TrimSpace(staffID), Name: strings.TrimSpace(fallbackName)}
profile, found, err := r.store.LookupDirectoryProfile(ctx, tenantKey, fallback.StaffID)
if err != nil {
return fallback, errors.New("directory local lookup failed")
}
if found && strings.TrimSpace(profile.Name) != "" {
profile.Name = strings.TrimSpace(profile.Name)
return profile, nil
}
if r.lookup == nil {
return fallback, nil
}
profile, err = r.lookup.LookupUser(ctx, fallback.StaffID)
if err != nil {
return fallback, errors.New("directory on-demand lookup failed")
}
profile.StaffID = fallback.StaffID
profile.Name = strings.TrimSpace(profile.Name)
if profile.Name == "" {
return fallback, errors.New("directory on-demand lookup returned no name")
}
profile.Source = SourceOnDemand
if profile.SyncedAt.IsZero() {
profile.SyncedAt = r.now().UTC()
}
if err := r.store.UpsertDirectoryProfiles(ctx, tenantKey, []Profile{profile}); err != nil {
return profile, errors.New("directory on-demand cache write failed")
}
return profile, nil
}
+93
View File
@@ -0,0 +1,93 @@
package directory
import (
"context"
"errors"
"testing"
)
type fakeStore struct {
profile Profile
found bool
readErr error
writeErr error
writes int
}
func (s *fakeStore) LookupDirectoryProfile(context.Context, string, string) (Profile, bool, error) {
return s.profile, s.found, s.readErr
}
func (s *fakeStore) UpsertDirectoryProfiles(_ context.Context, _ string, profiles []Profile) error {
s.writes++
if len(profiles) > 0 {
s.profile = profiles[0]
}
return s.writeErr
}
type fakeLookup struct {
profile Profile
err error
calls int
}
func (l *fakeLookup) LookupUser(context.Context, string) (Profile, error) {
l.calls++
return l.profile, l.err
}
func TestResolverUsesLocalProfileWithoutRemoteLookup(t *testing.T) {
store := &fakeStore{profile: Profile{StaffID: "staff-1", Name: "张三", DepartmentName: "技术支持部", Region: "APAC"}, found: true}
lookup := &fakeLookup{}
resolver, err := NewResolver(store, lookup)
if err != nil { t.Fatal(err) }
profile, err := resolver.ResolveUser(context.Background(), "tenant-a", "staff-1", "昵称")
if err != nil { t.Fatalf("ResolveUser: %v", err) }
if profile.Name != "张三" || lookup.calls != 0 || store.writes != 0 {
t.Fatalf("profile=%+v lookup=%d writes=%d", profile, lookup.calls, store.writes)
}
}
func TestResolverLooksUpAndCachesMissingUser(t *testing.T) {
store := &fakeStore{}
lookup := &fakeLookup{profile: Profile{Name: "李四", DepartmentName: "销售部", Region: "EMEA"}}
resolver, err := NewResolver(store, lookup)
if err != nil { t.Fatal(err) }
profile, err := resolver.ResolveUser(context.Background(), "tenant-a", "staff-2", "昵称")
if err != nil { t.Fatalf("ResolveUser: %v", err) }
if profile.StaffID != "staff-2" || profile.Name != "李四" || profile.Source != SourceOnDemand || lookup.calls != 1 || store.writes != 1 {
t.Fatalf("profile=%+v lookup=%d writes=%d", profile, lookup.calls, store.writes)
}
}
func TestResolverFallsBackWithoutBlockingOnFailures(t *testing.T) {
tests := []struct{
name string
store *fakeStore
lookup *fakeLookup
}{
{name: "local read fails", store: &fakeStore{readErr: errors.New("db unavailable")}, lookup: &fakeLookup{}},
{name: "remote lookup fails", store: &fakeStore{}, lookup: &fakeLookup{err: errors.New("remote unavailable")}},
{name: "cache write fails but resolved profile survives", store: &fakeStore{writeErr: errors.New("db unavailable")}, lookup: &fakeLookup{profile: Profile{Name: "王五"}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resolver, err := NewResolver(tt.store, tt.lookup)
if err != nil { t.Fatal(err) }
profile, resolveErr := resolver.ResolveUser(context.Background(), "tenant-a", "staff-3", " 回调昵称 ")
if profile.Name == "" { t.Fatalf("empty fallback profile: %+v", profile) }
if tt.name != "cache write fails but resolved profile survives" && profile.Name != "回调昵称" {
t.Fatalf("profile name=%q, want fallback", profile.Name)
}
if resolveErr == nil { t.Fatal("expected diagnostic error") }
})
}
}
func TestResolverWithoutRemoteUsesFallback(t *testing.T) {
resolver, err := NewResolver(&fakeStore{}, nil)
if err != nil { t.Fatal(err) }
profile, err := resolver.ResolveUser(context.Background(), "tenant-a", "staff-4", "赵六")
if err != nil || profile.Name != "赵六" || profile.StaffID != "staff-4" {
t.Fatalf("profile=%+v err=%v", profile, err)
}
}
+119
View File
@@ -0,0 +1,119 @@
package faqtable
import (
"context"
"errors"
"reflect"
"strings"
"time"
)
const FailureCodeRemoteInsertFailed = "remote_faq_table_insert_failed"
type PendingExport struct {
CaseID string
Row Row
Attempts int
}
type ExportRepository interface {
Claim(context.Context, int, int, time.Time) ([]PendingExport, error)
MarkExported(context.Context, string, string) error
MarkFailed(context.Context, string, string) error
}
type FieldsInserter interface {
InsertFields(context.Context, map[string]interface{}) (string, error)
}
type DispatcherOptions struct {
Batch int
MaxAttempts int
ClaimLease time.Duration
SendTimeout time.Duration
}
type DispatcherStats struct {
Claimed int
Exported int
Failed int
}
type Dispatcher struct {
repo ExportRepository
inserter FieldsInserter
batch int
maxAttempts int
claimLease time.Duration
sendTimeout time.Duration
}
func NewDispatcher(repo ExportRepository, inserter FieldsInserter, opts *DispatcherOptions) (*Dispatcher, error) {
if isNil(repo) || isNil(inserter) {
return nil, errors.New("faqtable dispatcher dependencies are required")
}
d := &Dispatcher{repo: repo, inserter: inserter, batch: 10, maxAttempts: 5, claimLease: 5 * time.Minute, sendTimeout: 10 * time.Second}
if opts != nil {
if opts.Batch < 0 || opts.MaxAttempts < 0 || opts.ClaimLease < 0 || opts.SendTimeout < 0 {
return nil, errors.New("faqtable dispatcher options are invalid")
}
if opts.Batch > 0 {
d.batch = opts.Batch
}
if opts.MaxAttempts > 0 {
d.maxAttempts = opts.MaxAttempts
}
if opts.ClaimLease > 0 {
d.claimLease = opts.ClaimLease
}
if opts.SendTimeout > 0 {
d.sendTimeout = opts.SendTimeout
}
}
return d, nil
}
func (d *Dispatcher) RunOnce(ctx context.Context) (DispatcherStats, error) {
var stats DispatcherStats
if err := ctx.Err(); err != nil {
return stats, err
}
items, err := d.repo.Claim(ctx, d.batch, d.maxAttempts, time.Now().Add(-d.claimLease))
if err != nil {
return stats, errors.New("faqtable dispatcher claim failed")
}
stats.Claimed = len(items)
for _, item := range items {
if err := ctx.Err(); err != nil {
return stats, err
}
itemCtx, cancel := context.WithTimeout(ctx, d.sendTimeout)
recordID, insertErr := d.inserter.InsertFields(itemCtx, item.Row.ToFields())
cancel()
if insertErr != nil || strings.TrimSpace(recordID) == "" {
if err := d.repo.MarkFailed(ctx, item.CaseID, FailureCodeRemoteInsertFailed); err != nil {
return stats, errors.New("faqtable dispatcher mark failed failed")
}
stats.Failed++
continue
}
if err := d.repo.MarkExported(ctx, item.CaseID, recordID); err != nil {
return stats, errors.New("faqtable dispatcher mark exported failed")
}
stats.Exported++
}
return stats, nil
}
func isNil(value interface{}) bool {
if value == nil {
return true
}
v := reflect.ValueOf(value)
switch v.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
return v.IsNil()
default:
return false
}
}
+83
View File
@@ -0,0 +1,83 @@
package faqtable
import (
"context"
"errors"
"testing"
"time"
)
type dispatcherRepoStub struct {
items []PendingExport
exported map[string]string
failed map[string]string
}
func (s *dispatcherRepoStub) Claim(context.Context, int, int, time.Time) ([]PendingExport, error) {
items := s.items
s.items = nil
return items, nil
}
func (s *dispatcherRepoStub) MarkExported(_ context.Context, id, recordID string) error {
if s.exported == nil {
s.exported = map[string]string{}
}
s.exported[id] = recordID
return nil
}
func (s *dispatcherRepoStub) MarkFailed(_ context.Context, id, code string) error {
if s.failed == nil {
s.failed = map[string]string{}
}
s.failed[id] = code
return nil
}
type fieldsInserterStub struct {
fields map[string]interface{}
id string
err error
}
func (s *fieldsInserterStub) InsertFields(_ context.Context, fields map[string]interface{}) (string, error) {
s.fields = fields
return s.id, s.err
}
func TestDispatcherExportsFAQAndPersistsRecordID(t *testing.T) {
repo := &dispatcherRepoStub{items: []PendingExport{{
CaseID: "case-1",
Row: Row{StandardQuestion: "中文问题", OriginalQuestion: "中文问题", AskerName: "张三", AskerUnionID: "union-1"},
}}}
inserter := &fieldsInserterStub{id: "record-1"}
d, err := NewDispatcher(repo, inserter, nil)
if err != nil {
t.Fatal(err)
}
stats, err := d.RunOnce(context.Background())
if err != nil {
t.Fatal(err)
}
if stats.Claimed != 1 || stats.Exported != 1 || stats.Failed != 0 {
t.Fatalf("stats=%+v", stats)
}
if repo.exported["case-1"] != "record-1" || inserter.fields[FieldLanguage] != "Chinese" {
t.Fatalf("exported=%#v fields=%#v", repo.exported, inserter.fields)
}
}
func TestDispatcherPersistsOnlyStableFAQFailureCode(t *testing.T) {
repo := &dispatcherRepoStub{items: []PendingExport{{CaseID: "case-2", Row: Row{StandardQuestion: "secret question", OriginalQuestion: "secret question"}}}}
inserter := &fieldsInserterStub{err: errors.New("token=secret raw question=secret question")}
d, err := NewDispatcher(repo, inserter, &DispatcherOptions{SendTimeout: time.Second})
if err != nil {
t.Fatal(err)
}
stats, err := d.RunOnce(context.Background())
if err != nil {
t.Fatal(err)
}
if stats.Failed != 1 || repo.failed["case-2"] != FailureCodeRemoteInsertFailed {
t.Fatalf("stats=%+v failed=%#v", stats, repo.failed)
}
}
+116
View File
@@ -0,0 +1,116 @@
package faqtable
import (
"errors"
"net/url"
"strings"
"unicode"
)
// Verified DingTalk Notable FAQ field IDs.
const (
FieldStandardQuestion = "01ZM8y7" // text: 标准问题(必填)
FieldOriginalQuestion = "fj3JQ1C" // text: 原始问题
FieldLanguage = "07YYagS" // text: 语言
FieldAsker = "Eu7gkKu" // user: 提问人
)
type Row struct {
StandardQuestion string
OriginalQuestion string
AskerName string
AskerUnionID string
}
func (r Row) ToFields() map[string]interface{} {
fields := map[string]interface{}{
FieldStandardQuestion: safeText(r.StandardQuestion),
FieldOriginalQuestion: safeText(r.OriginalQuestion),
FieldLanguage: DetectLanguage(r.OriginalQuestion),
}
if unionID := strings.TrimSpace(r.AskerUnionID); unionID != "" {
fields[FieldAsker] = []interface{}{map[string]interface{}{
"name": strings.TrimSpace(r.AskerName),
"unionId": unionID,
}}
}
return fields
}
func safeText(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if strings.ContainsRune("=+-@", rune(value[0])) {
return "'" + value
}
return value
}
func DetectLanguage(value string) string {
if strings.Contains(value, "日本語") {
return "Japanese"
}
var hasHan, hasKana, hasHangul, hasLatin, hasSpanish bool
for _, r := range value {
switch {
case unicode.In(r, unicode.Hiragana, unicode.Katakana):
hasKana = true
case unicode.In(r, unicode.Hangul):
hasHangul = true
case unicode.In(r, unicode.Han):
hasHan = true
case strings.ContainsRune("áéíóúüñÁÉÍÓÚÜÑ¿¡", r):
hasSpanish = true
case unicode.In(r, unicode.Latin):
hasLatin = true
}
}
switch {
case hasKana:
return "Japanese"
case hasHangul:
return "Korean"
case hasHan:
return "Chinese"
case hasSpanish:
return "Spanish"
case hasLatin:
return "English"
default:
return "Other"
}
}
type RecordLinkBuilder struct {
baseID string
sheetID string
viewID string
}
func NewRecordLinkBuilder(baseID, sheetID, viewID string) (*RecordLinkBuilder, error) {
baseID = strings.TrimSpace(baseID)
sheetID = strings.TrimSpace(sheetID)
viewID = strings.TrimSpace(viewID)
if baseID == "" || sheetID == "" || viewID == "" {
return nil, errors.New("faq record link configuration is incomplete")
}
return &RecordLinkBuilder{baseID: baseID, sheetID: sheetID, viewID: viewID}, nil
}
func (b *RecordLinkBuilder) Build(recordID string) (string, error) {
if b == nil {
return "", errors.New("faq record link builder is unavailable")
}
recordID = strings.TrimSpace(recordID)
if recordID == "" {
return "", errors.New("faq record id is required")
}
q := url.Values{}
q.Set("dentryUuid", b.baseID)
q.Set("rowId", recordID)
q.Set("sheetId", b.sheetID)
q.Set("viewId", b.viewID)
return (&url.URL{Scheme: "https", Host: "docs.dingtalk.com", Path: "/notable/record", RawQuery: q.Encode()}).String(), nil
}
+104
View File
@@ -0,0 +1,104 @@
package faqtable
import (
"net/url"
"strings"
"testing"
)
func TestRowToFieldsUsesVerifiedFAQContract(t *testing.T) {
fields := (Row{
StandardQuestion: "Nova5I支持哪些通信协议?",
OriginalQuestion: "Nova5I支持哪些通信协议?",
AskerName: "测试用户",
AskerUnionID: "union-1",
}).ToFields()
if got := fields[FieldStandardQuestion]; got != "Nova5I支持哪些通信协议?" {
t.Fatalf("standard question = %v", got)
}
if got := fields[FieldOriginalQuestion]; got != "Nova5I支持哪些通信协议?" {
t.Fatalf("original question = %v", got)
}
if got := fields[FieldLanguage]; got != "Chinese" {
t.Fatalf("language = %v, want Chinese", got)
}
users, ok := fields[FieldAsker].([]interface{})
if !ok || len(users) != 1 {
t.Fatalf("asker shape = %#v", fields[FieldAsker])
}
user, ok := users[0].(map[string]interface{})
if !ok || user["name"] != "测试用户" || user["unionId"] != "union-1" {
t.Fatalf("asker = %#v", users[0])
}
if len(fields) != 4 {
t.Fatalf("field count = %d, want exactly four", len(fields))
}
}
func TestRowToFieldsOmitsAskerWithoutUnionID(t *testing.T) {
fields := (Row{StandardQuestion: "Question", OriginalQuestion: "Question", AskerName: "name"}).ToFields()
if _, ok := fields[FieldAsker]; ok {
t.Fatal("asker must be omitted without unionId")
}
}
func TestDetectLanguageUsesFAQEnglishEnums(t *testing.T) {
cases := map[string]string{
"中文问题": "Chinese",
"日本語の質問": "Japanese",
"한국어 질문": "Korean",
"English question": "English",
"12345": "Other",
"日本語和漢字 mixed question": "Japanese",
}
for input, want := range cases {
if got := DetectLanguage(input); got != want {
t.Errorf("DetectLanguage(%q)=%q, want %q", input, got, want)
}
}
}
func TestRowToFieldsProtectsFormulaInjection(t *testing.T) {
fields := (Row{StandardQuestion: "=cmd", OriginalQuestion: "+cmd"}).ToFields()
if fields[FieldStandardQuestion] != "'=cmd" || fields[FieldOriginalQuestion] != "'+cmd" {
t.Fatalf("fields not sanitised: %#v", fields)
}
}
func TestRecordLinkBuilderUsesReturnedRecordIDAsRowID(t *testing.T) {
builder, err := NewRecordLinkBuilder("base+id", "sheet/id", "view id")
if err != nil {
t.Fatal(err)
}
got, err := builder.Build("record&id")
if err != nil {
t.Fatal(err)
}
parsed, err := url.Parse(got)
if err != nil {
t.Fatal(err)
}
if parsed.Scheme != "https" || parsed.Host != "docs.dingtalk.com" || parsed.Path != "/notable/record" {
t.Fatalf("unexpected link: %s", got)
}
q := parsed.Query()
if q.Get("dentryUuid") != "base+id" || q.Get("rowId") != "record&id" || q.Get("sheetId") != "sheet/id" || q.Get("viewId") != "view id" {
t.Fatalf("unexpected query: %#v", q)
}
}
func TestRecordLinkBuilderRejectsMissingConfigOrRecordID(t *testing.T) {
for _, args := range [][3]string{{"", "s", "v"}, {"b", "", "v"}, {"b", "s", ""}} {
if _, err := NewRecordLinkBuilder(args[0], args[1], args[2]); err == nil {
t.Fatalf("expected error for %#v", args)
}
}
builder, err := NewRecordLinkBuilder("b", "s", "v")
if err != nil {
t.Fatal(err)
}
if _, err := builder.Build(" "); err == nil || strings.Contains(err.Error(), "b") {
t.Fatalf("expected sanitised missing record error, got %v", err)
}
}
+26 -1
View File
@@ -25,9 +25,16 @@ type AuditService interface {
GetTurn(context.Context, string) (audit.TurnDetail, error)
}
type BuildInfo struct {
Version string `json:"version"`
CommitSHA string `json:"commit_sha"`
BuildTime string `json:"build_time"`
}
type Options struct {
AuditService AuditService
AdminToken string
BuildInfo BuildInfo
}
type Server struct {
@@ -35,6 +42,7 @@ type Server struct {
ingressToken string
auditService AuditService
adminToken string
buildInfo BuildInfo
mux *http.ServeMux
}
@@ -58,13 +66,25 @@ func NewWithOptions(service QAService, ingressToken string, opts Options) (*Serv
if hasAdminToken && constantTimeTokenEqual(adminToken, ingressToken) {
return nil, errors.New("DOBOTAI_ADMIN_TOKEN must differ from DOBOTAI_INGRESS_TOKEN")
}
buildInfo := opts.BuildInfo
if buildInfo.Version == "" {
buildInfo.Version = "dev"
}
if buildInfo.CommitSHA == "" {
buildInfo.CommitSHA = "unknown"
}
if buildInfo.BuildTime == "" {
buildInfo.BuildTime = "unknown"
}
s := &Server{
service: service, ingressToken: ingressToken,
auditService: opts.AuditService, adminToken: adminToken,
mux: http.NewServeMux(),
buildInfo: buildInfo,
mux: http.NewServeMux(),
}
s.mux.HandleFunc("GET /healthz", s.health)
s.mux.HandleFunc("GET /version", s.version)
s.mux.HandleFunc("POST /v1/qa", s.authorizeToken(s.ingressToken, s.ask))
if hasAuditService {
s.mux.HandleFunc("GET /v1/admin/audit/turns", s.authorizeToken(s.adminToken, s.listAuditTurns))
@@ -102,6 +122,11 @@ func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) version(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, s.buildInfo)
}
func (s *Server) ask(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
defer r.Body.Close()
+40
View File
@@ -25,6 +25,46 @@ func (f *captureQA) Ask(_ context.Context, request core.QARequest) (core.QAResul
return core.QAResult{TurnID: "turn-capture", Answer: "answer"}, nil
}
func TestVersionEndpointReturnsBuildMetadataWithoutAuthentication(t *testing.T) {
server, err := NewWithOptions(fakeQA{}, "ingress-secret", Options{
BuildInfo: BuildInfo{
Version: "2.1.0",
CommitSHA: "abc123def456",
BuildTime: "2026-07-27T12:00:00Z",
},
})
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/version", nil)
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
if result.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", result.Code, result.Body.String())
}
if got := result.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control=%q, want no-store", got)
}
want := `{"version":"2.1.0","commit_sha":"abc123def456","build_time":"2026-07-27T12:00:00Z"}`
if got := strings.TrimSpace(result.Body.String()); got != want {
t.Fatalf("body=%s, want %s", got, want)
}
}
func TestVersionEndpointUsesSafeDefaults(t *testing.T) {
server, err := New(fakeQA{}, "ingress-secret")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/version", nil)
result := httptest.NewRecorder()
server.Handler().ServeHTTP(result, request)
want := `{"version":"dev","commit_sha":"unknown","build_time":"unknown"}`
if got := strings.TrimSpace(result.Body.String()); result.Code != http.StatusOK || got != want {
t.Fatalf("status=%d body=%s, want %s", result.Code, got, want)
}
}
func TestQAEndpointRequiresTokenAndReturnsResult(t *testing.T) {
server, err := New(fakeQA{}, "ingress-secret")
if err != nil {
+64
View File
@@ -0,0 +1,64 @@
package outboundimage
import (
"bytes"
"context"
"errors"
"strings"
"time"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
)
type AliyunOSSStore struct {
client *oss.Client
bucket string
}
func NewAliyunOSSStore(endpoint, region, accessKeyID, accessKeySecret, bucket string) (*AliyunOSSStore, error) {
endpoint = strings.TrimSpace(endpoint)
region = strings.TrimSpace(region)
accessKeyID = strings.TrimSpace(accessKeyID)
accessKeySecret = strings.TrimSpace(accessKeySecret)
bucket = strings.TrimSpace(bucket)
if endpoint == "" || region == "" || accessKeyID == "" || accessKeySecret == "" || bucket == "" {
return nil, errors.New("outbound image: OSS endpoint, region, credentials, and bucket are required")
}
provider := credentials.NewStaticCredentialsProvider(accessKeyID, accessKeySecret, "")
config := oss.LoadDefaultConfig().
WithCredentialsProvider(provider).
WithRegion(region).
WithEndpoint(endpoint)
return &AliyunOSSStore{client: oss.NewClient(config), bucket: bucket}, nil
}
func (s *AliyunOSSStore) Put(ctx context.Context, key string, data []byte, contentType string) error {
if s == nil || s.client == nil || strings.TrimSpace(key) == "" || len(data) == 0 {
return errors.New("outbound image: invalid OSS put request")
}
_, err := s.client.PutObject(ctx, &oss.PutObjectRequest{
Bucket: oss.Ptr(s.bucket),
Key: oss.Ptr(key),
Body: bytes.NewReader(data),
ContentType: oss.Ptr(contentType),
})
if err != nil {
return errors.New("outbound image: OSS put failed")
}
return nil
}
func (s *AliyunOSSStore) PresignGet(ctx context.Context, key string, ttl time.Duration) (string, error) {
if s == nil || s.client == nil || strings.TrimSpace(key) == "" || ttl <= 0 {
return "", errors.New("outbound image: invalid OSS presign request")
}
result, err := s.client.Presign(ctx, &oss.GetObjectRequest{
Bucket: oss.Ptr(s.bucket),
Key: oss.Ptr(key),
}, oss.PresignExpires(ttl))
if err != nil {
return "", errors.New("outbound image: OSS presign failed")
}
return result.URL, nil
}

Some files were not shown because too many files have changed in this diff Show More