Compare commits

...

8 Commits

23 changed files with 2037 additions and 48 deletions
+4
View File
@@ -22,6 +22,10 @@ DOBOTAI_TENANT_KEY=
# When set, a successful QA answer will be delivered as an AI card; markdown
# is only sent as a fallback when card delivery fails. Never commit real values.
DINGTALK_CARD_TEMPLATE_ID=
# Optional, temporary DingTalk card-debug allowlist. When set, only this exact
# DingTalk user receives _CARD_DEBUG_TOOL_ENTRY=show in per-user privateData.
# Keep empty outside a controlled debug session; never commit a real user ID.
DINGTALK_CARD_DEBUG_USER_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
+21 -2
View File
@@ -14,6 +14,7 @@ import (
"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/feedback"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/httpapi"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/qa"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
@@ -73,6 +74,14 @@ func main() {
}
var streamClient *dingtalk.StreamClient
if dingCfg.Enabled() {
feedbackService, err := feedback.NewService(dingCfg.TenantKey, "dingtalk", store)
if err != nil {
log.Fatal(err)
}
feedbackHandler, err := dingtalk.NewCardFeedbackHandler(feedbackService)
if err != nil {
log.Fatal(err)
}
sender, err := dingtalk.NewWebhookSender(nil)
if err != nil {
log.Fatal(err)
@@ -83,10 +92,20 @@ func main() {
}
var cardSender dingtalk.FinalCardSender
if cardTemplateID := strings.TrimSpace(os.Getenv("DINGTALK_CARD_TEMPLATE_ID")); cardTemplateID != "" {
cs, err := dingtalk.NewCardSender(dingCfg.ClientID, dingCfg.ClientSecret, cardTemplateID, nil)
cardDebugUserID := os.Getenv("DINGTALK_CARD_DEBUG_USER_ID")
cs, err := dingtalk.NewCardSender(
dingCfg.ClientID,
dingCfg.ClientSecret,
cardTemplateID,
nil,
dingtalk.WithCardDebugUserID(cardDebugUserID),
)
if err != nil {
log.Fatalf("DingTalk card sender init failed: %v", err)
}
if strings.TrimSpace(cardDebugUserID) != "" {
log.Printf("DingTalk card debug enabled for one allowlisted user")
}
cardSender = cs
}
handler, err := dingtalk.NewHandlerWithOptions(
@@ -98,7 +117,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
streamClient, err = dingtalk.NewStreamClient(dingCfg, handler)
streamClient, err = dingtalk.NewStreamClient(dingCfg, handler, feedbackHandler)
if err != nil {
log.Fatal(err)
}
+36
View File
@@ -2,6 +2,8 @@ package main
import (
"context"
"os"
"strings"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
@@ -65,3 +67,37 @@ func TestBuildAuditOptionsFixesServerSideTenant(t *testing.T) {
t.Fatalf("tenant=%q", repo.tenant)
}
}
func TestMainWiresFeedbackIntoDingTalkStream(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, fragment := range []string{
`"gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"`,
`feedback.NewService(dingCfg.TenantKey, "dingtalk", store)`,
`dingtalk.NewCardFeedbackHandler(feedbackService)`,
`dingtalk.NewStreamClient(dingCfg, handler, feedbackHandler)`,
} {
if !strings.Contains(source, fragment) {
t.Errorf("main feedback wiring missing %q", fragment)
}
}
}
func TestMainWiresOptionalDingTalkCardDebugUser(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_CARD_DEBUG_USER_ID")`,
`dingtalk.WithCardDebugUserID(cardDebugUserID)`,
} {
if !strings.Contains(source, fragment) {
t.Errorf("main card debug wiring missing %q", fragment)
}
}
}
@@ -0,0 +1,7 @@
-- 000005_answer_feedback down migration
-- Drop in reverse order: indexes first, then table.
DROP INDEX IF EXISTS idx_messages_card_provider_unique;
DROP INDEX IF EXISTS idx_answer_feedback_tenant_reason_updated;
DROP INDEX IF EXISTS idx_answer_feedback_tenant_value_updated;
DROP TABLE IF EXISTS answer_feedback;
@@ -0,0 +1,32 @@
-- 000005_answer_feedback: user feedback on answers via DingTalk card
-- Each (tenant_key, channel, answer_message_id, actor_id) holds the current
-- feedback state: up or down with optional reason_code for down.
-- Depends on: 000004_delivery_audit (and its 000003 message-audit baseline).
CREATE TABLE IF NOT EXISTS answer_feedback (
id TEXT PRIMARY KEY,
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
answer_message_id TEXT NOT NULL REFERENCES messages(id),
turn_id TEXT NOT NULL REFERENCES turns(id),
actor_id TEXT NOT NULL REFERENCES channel_actors(id),
value TEXT NOT NULL CHECK (value IN ('up', 'down')),
reason_code TEXT NOT NULL DEFAULT '' CHECK (reason_code IN ('', 'incorrect', 'not_resolved', 'irrelevant', 'unclear', 'outdated', 'other')),
source TEXT NOT NULL CHECK (source IN ('dingtalk_card')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_key, channel, answer_message_id, actor_id),
CHECK (value != 'up' OR reason_code = '')
);
-- Queries that filter by value or reason_code within a tenant use these indexes.
CREATE INDEX IF NOT EXISTS idx_answer_feedback_tenant_value_updated
ON answer_feedback (tenant_key, value, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_answer_feedback_tenant_reason_updated
ON answer_feedback (tenant_key, reason_code, updated_at DESC);
-- A DingTalk outTrackId must identify exactly one delivered card answer.
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_card_provider_unique
ON messages (tenant_key, channel, provider_message_id)
WHERE provider_message_id != '' AND delivery_method = 'card';
@@ -0,0 +1,296 @@
// Package migrations_test contains source-level contract tests for the
// 000005_answer_feedback migration. No real database connection is used.
package migrations_test
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
func read005UpMigration(t *testing.T) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000005_answer_feedback.up.sql"))
if err != nil {
t.Fatalf("read up migration: %v", err)
}
return string(data)
}
func read005DownMigration(t *testing.T) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000005_answer_feedback.down.sql"))
if err != nil {
t.Fatalf("read down migration: %v", err)
}
return string(data)
}
// --- RED: These tests will fail until the migration files exist ---
// TestUp005CreatesAnswerFeedbackTable ensures the table is created with IF NOT EXISTS.
func TestUp005CreatesAnswerFeedbackTable(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
if !strings.Contains(upper, "CREATE TABLE IF NOT EXISTS ANSWER_FEEDBACK") {
t.Error("up migration must create answer_feedback table with IF NOT EXISTS")
}
}
// TestUp005AnswerFeedbackColumns ensures all required columns are present.
func TestUp005AnswerFeedbackColumns(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
required := []string{
"ID TEXT PRIMARY KEY",
"TENANT_KEY TEXT NOT NULL",
"CHANNEL TEXT NOT NULL",
"ANSWER_MESSAGE_ID TEXT NOT NULL",
"TURN_ID TEXT NOT NULL",
"ACTOR_ID TEXT NOT NULL",
"VALUE TEXT NOT NULL",
"REASON_CODE TEXT NOT NULL DEFAULT ''",
"SOURCE TEXT NOT NULL",
"CREATED_AT TIMESTAMPTZ NOT NULL DEFAULT NOW()",
"UPDATED_AT TIMESTAMPTZ NOT NULL DEFAULT NOW()",
}
for _, col := range required {
if !strings.Contains(upper, col) {
t.Errorf("up migration must contain column %q", col)
}
}
}
// TestUp005ValueCheck ensures value is constrained to 'up' or 'down'.
func TestUp005ValueCheck(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
if !strings.Contains(upper, "VALUE IN ('UP'") || !strings.Contains(upper, "'DOWN'") {
t.Error("value must be CHECK constrained to 'up' and 'down'")
}
}
// TestUp005ReasonCodeCheck ensures reason_code is constrained to allowed values.
func TestUp005ReasonCodeCheck(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
allowed := []string{"''", "INCORRECT", "NOT_RESOLVED", "IRRELEVANT", "UNCLEAR", "OUTDATED", "OTHER"}
for _, r := range allowed {
if !strings.Contains(upper, r) {
t.Errorf("reason_code CHECK must include %q", r)
}
}
}
// TestUp005SourceCheck ensures source is constrained to 'dingtalk_card'.
func TestUp005SourceCheck(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
if !strings.Contains(upper, "DINGTALK_CARD") {
t.Error("source must be CHECK constrained to 'dingtalk_card'")
}
}
// TestUp005UpValueMustHaveNoReason ensures CHECK that value='up' implies reason_code=”.
func TestUp005UpValueMustHaveNoReason(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
// Must have a CHECK that when value='up', reason_code must be empty
hasCheck := strings.Contains(upper, "VALUE = 'UP'") && strings.Contains(upper, "REASON_CODE = ''")
// Also check for the inverse: "VALUE != 'UP' OR REASON_CODE = ''"
hasOr := strings.Contains(upper, "OR") && strings.Contains(upper, "REASON_CODE = ''")
if !hasCheck && !hasOr {
t.Error("must have CHECK ensuring value='up' implies reason_code=''")
}
}
// TestUp005ForeignKeyConstraints ensures FK constraints on answer_message_id, turn_id, actor_id.
func TestUp005ForeignKeyConstraints(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
// Must reference messages(id), turns(id), channel_actors(id)
if !strings.Contains(upper, "REFERENCES MESSAGES(ID)") {
t.Error("answer_message_id must have FK REFERENCES messages(id)")
}
if !strings.Contains(upper, "REFERENCES TURNS(ID)") {
t.Error("turn_id must have FK REFERENCES turns(id)")
}
if !strings.Contains(upper, "REFERENCES CHANNEL_ACTORS(ID)") {
t.Error("actor_id must have FK REFERENCES channel_actors(id)")
}
}
// TestUp005UniqueConstraint ensures UNIQUE on (tenant_key, channel, answer_message_id, actor_id).
func TestUp005UniqueConstraint(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
hasUnique := strings.Contains(upper, "UNIQUE") &&
strings.Contains(upper, "TENANT_KEY") &&
strings.Contains(upper, "CHANNEL") &&
strings.Contains(upper, "ANSWER_MESSAGE_ID") &&
strings.Contains(upper, "ACTOR_ID")
if !hasUnique {
t.Error("must have UNIQUE constraint on (tenant_key, channel, answer_message_id, actor_id)")
}
}
// TestUp005Indexes ensures required indexes are created.
func TestUp005Indexes(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
// Index on (tenant_key, value, updated_at DESC)
hasValueIdx := strings.Contains(upper, "TENANT_KEY") &&
strings.Contains(upper, "VALUE") &&
strings.Contains(upper, "UPDATED_AT") &&
strings.Contains(upper, "DESC")
if !hasValueIdx {
t.Error("must have index on (tenant_key, value, updated_at DESC)")
}
// Index on (tenant_key, reason_code, updated_at DESC)
hasReasonIdx := strings.Contains(upper, "TENANT_KEY") &&
strings.Contains(upper, "REASON_CODE") &&
strings.Contains(upper, "UPDATED_AT") &&
strings.Contains(upper, "DESC")
if !hasReasonIdx {
t.Error("must have index on (tenant_key, reason_code, updated_at DESC)")
}
// Must use CREATE INDEX IF NOT EXISTS
if got := countMatchesRe(up, `(?i)CREATE\s+INDEX\s+IF\s+NOT\s+EXISTS`); got < 2 {
t.Errorf("expected at least 2 CREATE INDEX IF NOT EXISTS, got %d", got)
}
}
// TestUp005UpMigrationUsesIfNotExists ensures idempotency.
func TestUp005UpMigrationUsesIfNotExists(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
if strings.Contains(upper, "CREATE TABLE") && !strings.Contains(upper, "CREATE TABLE IF NOT EXISTS") {
t.Error("CREATE TABLE must use IF NOT EXISTS for idempotency")
}
// Must have exactly 1 CREATE TABLE IF NOT EXISTS
if got := countMatchesRe(up, `(?i)CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS`); got != 1 {
t.Errorf("expected 1 CREATE TABLE IF NOT EXISTS, got %d", got)
}
}
func TestUp005CreatesUniqueCardProviderIndex(t *testing.T) {
up := strings.ToUpper(read005UpMigration(t))
for _, fragment := range []string{
"CREATE UNIQUE INDEX IF NOT EXISTS IDX_MESSAGES_CARD_PROVIDER_UNIQUE",
"ON MESSAGES (TENANT_KEY, CHANNEL, PROVIDER_MESSAGE_ID)",
"PROVIDER_MESSAGE_ID != ''",
"DELIVERY_METHOD = 'CARD'",
} {
if !strings.Contains(up, fragment) {
t.Errorf("provider binding index missing %q", fragment)
}
}
down := strings.ToUpper(read005DownMigration(t))
if !strings.Contains(down, "DROP INDEX IF EXISTS IDX_MESSAGES_CARD_PROVIDER_UNIQUE") {
t.Error("down migration must remove the B2 provider binding index")
}
}
// TestUp005NoForbiddenColumns ensures no secrets/protocol columns.
func TestUp005NoForbiddenColumns(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
forbidden := []string{
"WEBHOOK", "TOKEN", "RAW_CALLBACK", "RAW_PAYLOAD",
"CONTENT", "CALLBACK", "EXTENSION", "CORP_ID",
"SESSIONWEBHOOK", "DOWNLOAD_CODE", "DATAURI", "DATA_URI",
"RAW_RESPONSE", "RAW_ERROR", "PROVIDER_RESPONSE", "API_RESPONSE",
}
for _, col := range forbidden {
if strings.Contains(upper, col) {
t.Errorf("migration must not contain forbidden column %q", col)
}
}
}
// TestUp005DoesNotModifyBaselineTables ensures migration only touches answer_feedback.
func TestUp005DoesNotModifyBaselineTables(t *testing.T) {
up := read005UpMigration(t)
upper := strings.ToUpper(up)
// Must not ALTER other tables
baseline := []string{"MESSAGES", "TURNS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS", "SESSION_MAPPINGS"}
for _, tbl := range baseline {
if strings.Contains(upper, "ALTER TABLE "+tbl) {
t.Errorf("migration must not ALTER TABLE %s", tbl)
}
}
}
// TestDown005DropsAnswerFeedbackTable ensures down drops only the new table and indexes.
func TestDown005DropsAnswerFeedbackTable(t *testing.T) {
down := read005DownMigration(t)
upper := strings.ToUpper(down)
if !strings.Contains(upper, "DROP TABLE IF EXISTS ANSWER_FEEDBACK") {
t.Error("down migration must drop answer_feedback with IF EXISTS")
}
}
// TestDown005DoesNotDropBaselineTables ensures down does not touch B1 tables.
func TestDown005DoesNotDropBaselineTables(t *testing.T) {
down := read005DownMigration(t)
upper := strings.ToUpper(down)
baseline := []string{"MESSAGES", "TURNS", "CHANNEL_ACTORS", "CHANNEL_CONVERSATIONS", "SESSION_MAPPINGS"}
for _, tbl := range baseline {
if strings.Contains(upper, "DROP TABLE IF EXISTS "+tbl) {
t.Errorf("down migration must not drop baseline table %s", tbl)
}
}
}
// TestDown005DoesNotDropBaselineColumns ensures down does not drop baseline columns.
func TestDown005DoesNotDropBaselineColumns(t *testing.T) {
down := read005DownMigration(t)
upper := strings.ToUpper(down)
if strings.Contains(upper, "DROP COLUMN") || strings.Contains(upper, "ALTER TABLE") {
t.Error("down migration must not drop/alter columns — only drop the answer_feedback table and indexes")
}
}
// TestUp005NoReservedWords ensures no bare reserved words as column names.
func TestUp005NoReservedWords(t *testing.T) {
up := read005UpMigration(t)
// Check for bare "references" as column name (not in FK context)
bareRefs := regexp.MustCompile(`(?im)^\s*REFERENCES\s+`)
if bareRefs.MatchString(up) {
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, "FOREIGN KEY") {
t.Errorf("reserved word 'references' used as column name: %s", trimmed)
}
}
}
}
// --- Helpers ---
func countMatchesRe(haystack, pattern string) int {
return len(regexp.MustCompile(pattern).FindAllString(haystack, -1))
}
@@ -0,0 +1,29 @@
-- 000006_native_card_feedback_reasons down migration
-- A schema rollback cannot retain values rejected by the legacy constraint,
-- so map each native reason to its closest B2 legacy category first.
UPDATE answer_feedback
SET reason_code = CASE reason_code
WHEN 'content_mismatch' THEN 'irrelevant'
WHEN 'content_incomplete' THEN 'not_resolved'
WHEN 'illogical_expression' THEN 'unclear'
WHEN 'factual_error' THEN 'incorrect'
WHEN 'sensitive_content' THEN 'other'
WHEN 'answer_not_wanted' THEN 'not_resolved'
ELSE reason_code
END
WHERE reason_code IN (
'content_mismatch',
'content_incomplete',
'illogical_expression',
'factual_error',
'sensitive_content',
'answer_not_wanted'
);
ALTER TABLE answer_feedback
DROP CONSTRAINT IF EXISTS answer_feedback_reason_code_check;
ALTER TABLE answer_feedback
ADD CONSTRAINT answer_feedback_reason_code_check
CHECK (reason_code IN ('', 'incorrect', 'not_resolved', 'irrelevant', 'unclear', 'outdated', 'other'));
@@ -0,0 +1,24 @@
-- 000006_native_card_feedback_reasons up migration
-- Preserve the original B2 reasons and add the six exact reason codes emitted
-- by DingTalk's built-in AI feedback component.
ALTER TABLE answer_feedback
DROP CONSTRAINT IF EXISTS answer_feedback_reason_code_check;
ALTER TABLE answer_feedback
ADD CONSTRAINT answer_feedback_reason_code_check
CHECK (reason_code IN (
'',
'incorrect',
'not_resolved',
'irrelevant',
'unclear',
'outdated',
'other',
'content_mismatch',
'content_incomplete',
'illogical_expression',
'factual_error',
'sensitive_content',
'answer_not_wanted'
));
@@ -0,0 +1,78 @@
package migrations_test
import (
"os"
"path/filepath"
"strings"
"testing"
)
func read006Migration(t *testing.T, direction string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000006_native_card_feedback_reasons."+direction+".sql"))
if err != nil {
t.Fatalf("read 000006 %s migration: %v", direction, err)
}
return strings.Join(strings.Fields(strings.ToUpper(string(data))), " ")
}
func TestUp006ExpandsNamedReasonConstraint(t *testing.T) {
up := read006Migration(t, "up")
for _, fragment := range []string{
"ALTER TABLE ANSWER_FEEDBACK DROP CONSTRAINT IF EXISTS ANSWER_FEEDBACK_REASON_CODE_CHECK",
"ADD CONSTRAINT ANSWER_FEEDBACK_REASON_CODE_CHECK",
"CHECK (REASON_CODE IN",
"CONTENT_MISMATCH",
"CONTENT_INCOMPLETE",
"ILLOGICAL_EXPRESSION",
"FACTUAL_ERROR",
"SENSITIVE_CONTENT",
"ANSWER_NOT_WANTED",
} {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing %q", fragment)
}
}
for _, legacy := range []string{"INCORRECT", "NOT_RESOLVED", "IRRELEVANT", "UNCLEAR", "OUTDATED", "OTHER"} {
if !strings.Contains(up, legacy) {
t.Errorf("up migration must preserve legacy reason %q", legacy)
}
}
}
func TestUp006OnlyChangesReasonConstraint(t *testing.T) {
up := read006Migration(t, "up")
for _, forbidden := range []string{"DROP TABLE", "DROP COLUMN", "TRUNCATE", "DELETE FROM", "UPDATE ANSWER_FEEDBACK"} {
if strings.Contains(up, forbidden) {
t.Errorf("up migration contains forbidden operation %q", forbidden)
}
}
}
func TestDown006MapsNativeReasonsBeforeRestoringConstraint(t *testing.T) {
down := read006Migration(t, "down")
mappings := map[string]string{
"CONTENT_MISMATCH": "IRRELEVANT",
"CONTENT_INCOMPLETE": "NOT_RESOLVED",
"ILLOGICAL_EXPRESSION": "UNCLEAR",
"FACTUAL_ERROR": "INCORRECT",
"SENSITIVE_CONTENT": "OTHER",
"ANSWER_NOT_WANTED": "NOT_RESOLVED",
}
updateAt := strings.Index(down, "UPDATE ANSWER_FEEDBACK")
constraintAt := strings.Index(down, "ADD CONSTRAINT ANSWER_FEEDBACK_REASON_CODE_CHECK")
if updateAt < 0 || constraintAt < 0 || updateAt > constraintAt {
t.Fatal("down migration must map native reasons before restoring the legacy constraint")
}
for from, to := range mappings {
if !strings.Contains(down, "WHEN '"+from+"' THEN '"+to+"'") {
t.Errorf("down migration missing mapping %s -> %s", from, to)
}
}
for _, native := range []string{"CONTENT_MISMATCH", "CONTENT_INCOMPLETE", "ILLOGICAL_EXPRESSION", "FACTUAL_ERROR", "SENSITIVE_CONTENT", "ANSWER_NOT_WANTED"} {
constraint := down[constraintAt:]
if strings.Contains(constraint, native) {
t.Errorf("legacy down constraint must exclude native reason %q", native)
}
}
}
+44 -30
View File
@@ -25,19 +25,22 @@ const (
// It must never contain full question/answer text, error_message, WeKnora
// session/request IDs, or provider_message_id.
type TurnSummary struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Channel string `json:"channel"`
Status TurnStatus `json:"status"`
ConversationType string `json:"conversation_type"`
ConversationTitle string `json:"conversation_title,omitempty"`
ActorExternalID string `json:"actor_external_id,omitempty"`
ActorDisplayName string `json:"actor_display_name,omitempty"`
QuestionSummary string `json:"question_summary"`
AnswerSummary string `json:"answer_summary"`
ReferenceCount int `json:"reference_count"`
AnswerDeliveryStatus string `json:"answer_delivery_status,omitempty"`
AnswerDeliveryMethod string `json:"answer_delivery_method,omitempty"`
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Channel string `json:"channel"`
Status TurnStatus `json:"status"`
ConversationType string `json:"conversation_type"`
ConversationTitle string `json:"conversation_title,omitempty"`
ActorExternalID string `json:"actor_external_id,omitempty"`
ActorDisplayName string `json:"actor_display_name,omitempty"`
QuestionSummary string `json:"question_summary"`
AnswerSummary string `json:"answer_summary"`
ReferenceCount int `json:"reference_count"`
AnswerDeliveryStatus string `json:"answer_delivery_status,omitempty"`
AnswerDeliveryMethod string `json:"answer_delivery_method,omitempty"`
FeedbackUpCount int `json:"feedback_up_count"`
FeedbackDownCount int `json:"feedback_down_count"`
FeedbackUnresolvedCount int `json:"feedback_unresolved_count"`
}
// SafeReference is a reference with only safe fields exposed.
@@ -72,27 +75,38 @@ type MessageMeta struct {
FailureCode string `json:"failure_code,omitempty"`
}
// AnswerFeedback is the safe current feedback state for one actor. Provider
// tracking IDs, internal IDs and raw callback fields are intentionally absent.
type AnswerFeedback struct {
ActorExternalID string `json:"actor_external_id"`
ActorDisplayName string `json:"actor_display_name,omitempty"`
Value string `json:"value"`
ReasonCode string `json:"reason_code"`
UpdatedAt time.Time `json:"updated_at"`
}
// TurnDetail is the full detail view of a turn. It must never include
// error_message, WeKnora session/request ID, provider_message_id,
// sessionWebhook, tokens, raw API/error, or Reference.Content/Raw.
type TurnDetail struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Channel string `json:"channel"`
Status TurnStatus `json:"status"`
ConversationType string `json:"conversation_type"`
ConversationTitle string `json:"conversation_title,omitempty"`
ActorExternalID string `json:"actor_external_id,omitempty"`
ActorDisplayName string `json:"actor_display_name,omitempty"`
Question string `json:"question"`
Answer string `json:"answer"`
References []SafeReference `json:"references,omitempty"`
Media []SafeMedia `json:"media,omitempty"`
RequestMessage *MessageMeta `json:"request_message,omitempty"`
AnswerMessage *MessageMeta `json:"answer_message,omitempty"`
ReferenceCount int `json:"reference_count"`
AnswerDeliveryStatus string `json:"answer_delivery_status,omitempty"`
AnswerDeliveryMethod string `json:"answer_delivery_method,omitempty"`
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Channel string `json:"channel"`
Status TurnStatus `json:"status"`
ConversationType string `json:"conversation_type"`
ConversationTitle string `json:"conversation_title,omitempty"`
ActorExternalID string `json:"actor_external_id,omitempty"`
ActorDisplayName string `json:"actor_display_name,omitempty"`
Question string `json:"question"`
Answer string `json:"answer"`
References []SafeReference `json:"references,omitempty"`
Media []SafeMedia `json:"media,omitempty"`
RequestMessage *MessageMeta `json:"request_message,omitempty"`
AnswerMessage *MessageMeta `json:"answer_message,omitempty"`
ReferenceCount int `json:"reference_count"`
AnswerDeliveryStatus string `json:"answer_delivery_status,omitempty"`
AnswerDeliveryMethod string `json:"answer_delivery_method,omitempty"`
Feedback []AnswerFeedback `json:"feedback,omitempty"`
}
// ListTurnsRequest is the input for listing turns.
+43
View File
@@ -0,0 +1,43 @@
package audit
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestFeedbackProjectionJSONUsesOnlySafeFields(t *testing.T) {
updated := time.Date(2026, 7, 21, 15, 0, 0, 0, time.UTC)
detail := TurnDetail{Feedback: []AnswerFeedback{{
ActorExternalID: "user-1", ActorDisplayName: "Alice", Value: "down", ReasonCode: "not_resolved", UpdatedAt: updated,
}}}
raw, err := json.Marshal(detail)
if err != nil {
t.Fatal(err)
}
body := string(raw)
for _, want := range []string{`"feedback"`, `"actor_external_id":"user-1"`, `"actor_display_name":"Alice"`, `"value":"down"`, `"reason_code":"not_resolved"`, `"updated_at"`} {
if !strings.Contains(body, want) {
t.Errorf("feedback JSON missing %s: %s", want, body)
}
}
for _, forbidden := range []string{"out_track", "provider_message", "answer_message_id", "actor_id", "token", "callback", "params"} {
if strings.Contains(strings.ToLower(body), forbidden) {
t.Errorf("feedback JSON leaked %q: %s", forbidden, body)
}
}
}
func TestTurnSummaryFeedbackCountsJSON(t *testing.T) {
raw, err := json.Marshal(TurnSummary{FeedbackUpCount: 2, FeedbackDownCount: 3, FeedbackUnresolvedCount: 1})
if err != nil {
t.Fatal(err)
}
body := string(raw)
for _, want := range []string{`"feedback_up_count":2`, `"feedback_down_count":3`, `"feedback_unresolved_count":1`} {
if !strings.Contains(body, want) {
t.Errorf("summary JSON missing %s: %s", want, body)
}
}
}
+45 -12
View File
@@ -17,8 +17,9 @@ import (
)
const (
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
cardDebugPrivateKey = "_CARD_DEBUG_TOOL_ENTRY"
)
// CardRequest is the input for sending a final AI card.
@@ -39,26 +40,38 @@ type CardSender struct {
streamURL string
apiClient *http.Client
allowTestURL bool
debugUserID string
mu sync.Mutex
token string
tokenTill time.Time
}
// CardSenderOption configures optional CardSender behavior.
type CardSenderOption func(*CardSender)
// WithCardDebugUserID enables DingTalk's official card debug entry only for
// the matching user through per-user privateData. A blank value keeps it off.
func WithCardDebugUserID(userID string) CardSenderOption {
return func(sender *CardSender) {
sender.debugUserID = strings.TrimSpace(userID)
}
}
// NewCardSender creates a CardSender. All three config values are required
// after trimming whitespace. A nil http.Client installs a hardened default
// (no redirects, Proxy=nil, 15s timeout).
func NewCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client) (*CardSender, error) {
return newCardSender(clientID, clientSecret, cardTemplateID, client, oauthTokenURL, cardCreateURL, cardStreamingURL, false)
func NewCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, options ...CardSenderOption) (*CardSender, error) {
return newCardSender(clientID, clientSecret, cardTemplateID, client, oauthTokenURL, cardCreateURL, cardStreamingURL, false, options...)
}
// newCardSenderForTest is used by tests to inject httptest URLs.
func newCardSenderForTest(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL string) (*CardSender, error) {
func newCardSenderForTest(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL string, options ...CardSenderOption) (*CardSender, error) {
streamURL := strings.TrimSuffix(createURL, "/create") + "/stream"
return newCardSender(clientID, clientSecret, cardTemplateID, client, tokenURL, createURL, streamURL, true)
return newCardSender(clientID, clientSecret, cardTemplateID, client, tokenURL, createURL, streamURL, true, options...)
}
func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL, streamURL string, allowTest bool) (*CardSender, error) {
func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL, streamURL string, allowTest bool, options ...CardSenderOption) (*CardSender, error) {
clientID = strings.TrimSpace(clientID)
clientSecret = strings.TrimSpace(clientSecret)
cardTemplateID = strings.TrimSpace(cardTemplateID)
@@ -91,7 +104,7 @@ func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.C
apiClient.Transport = transport
}
return &CardSender{
sender := &CardSender{
clientID: clientID,
clientSecret: clientSecret,
cardTemplateID: cardTemplateID,
@@ -100,7 +113,13 @@ func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.C
streamURL: streamURL,
apiClient: &apiClient,
allowTestURL: allowTest,
}, nil
}
for _, option := range options {
if option != nil {
option(sender)
}
}
return sender, nil
}
// SendFinalCard sends the AI card to DingTalk and returns the generated outTrackID.
@@ -134,9 +153,10 @@ func (s *CardSender) StartCard(ctx context.Context, req CardRequest) (string, er
return "", errors.New("dingtalk card: content exceeds 64 KiB")
}
// Validate identity fields
// Validate identity fields. ExternalUserID is also available for group
// askers and is used only for per-user card private data.
req.ExternalUserID = strings.TrimSpace(req.ExternalUserID)
if req.ConversationType == core.ConversationDirect {
req.ExternalUserID = strings.TrimSpace(req.ExternalUserID)
if req.ExternalUserID == "" {
return "", errors.New("dingtalk card: ExternalUserID is required for direct conversations")
}
@@ -202,7 +222,11 @@ func (s *CardSender) buildCardBody(req CardRequest, outTrackID string) map[strin
"callbackType": "STREAM",
"cardData": map[string]any{
"cardParamMap": map[string]any{
"content": req.Content,
"content": req.Content,
"feedbackSelected": "",
"feedbackReasonSelected": "",
"showFeedbackReasons": "false",
"feedbackMessage": "",
},
},
"userIdType": 1,
@@ -228,6 +252,15 @@ func (s *CardSender) buildCardBody(req CardRequest, outTrackID string) map[strin
"extension": map[string]any{},
}
}
if s.debugUserID != "" && req.ExternalUserID == s.debugUserID {
body["privateData"] = map[string]any{
req.ExternalUserID: map[string]any{
"cardParamMap": map[string]any{
cardDebugPrivateKey: "show",
},
},
}
}
return body
}
+121
View File
@@ -218,6 +218,16 @@ func TestCardSendDirectPayload(t *testing.T) {
if paramMap["content"] != "Hello from AI" {
t.Errorf("content=%v", paramMap["content"])
}
for key, want := range map[string]any{
"feedbackSelected": "",
"feedbackReasonSelected": "",
"showFeedbackReasons": "false",
"feedbackMessage": "",
} {
if paramMap[key] != want {
t.Errorf("%s=%v, want %v", key, paramMap[key], want)
}
}
if body["userIdType"] != float64(1) {
t.Errorf("userIdType=%v", body["userIdType"])
}
@@ -261,6 +271,117 @@ func TestCardSendDirectPayload(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Test: official DingTalk card debug private data for one allowlisted user
// ---------------------------------------------------------------------------
func TestCardSendDirectAddsDebugPrivateDataForSelectedUser(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest(
"client-id",
"client-secret",
"card-template-123",
ts.Client(),
ts.URL()+"/token",
ts.URL()+"/create",
WithCardDebugUserID(" user-debug "),
)
if err != nil {
t.Fatal(err)
}
_, err = sender.SendFinalCard(context.Background(), CardRequest{
ConversationType: core.ConversationDirect,
ExternalUserID: "user-debug",
Content: "debug this card",
})
if err != nil {
t.Fatalf("SendFinalCard: %v", err)
}
privateData, ok := ts.lastCreateBody["privateData"].(map[string]any)
if !ok {
t.Fatal("privateData missing or wrong type")
}
userData, ok := privateData["user-debug"].(map[string]any)
if !ok {
t.Fatal("selected user's privateData missing or wrong type")
}
paramMap, ok := userData["cardParamMap"].(map[string]any)
if !ok {
t.Fatal("selected user's cardParamMap missing or wrong type")
}
if got := paramMap["_CARD_DEBUG_TOOL_ENTRY"]; got != "show" {
t.Fatalf("_CARD_DEBUG_TOOL_ENTRY=%v, want show", got)
}
if len(privateData) != 1 {
t.Fatalf("privateData has %d users, want 1", len(privateData))
}
}
func TestCardSendOmitsDebugPrivateDataForOtherUser(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest(
"client-id", "client-secret", "card-template-123",
ts.Client(), ts.URL()+"/token", ts.URL()+"/create",
WithCardDebugUserID("user-debug"),
)
if err != nil {
t.Fatal(err)
}
_, err = sender.SendFinalCard(context.Background(), CardRequest{
ConversationType: core.ConversationDirect,
ExternalUserID: "user-other",
Content: "ordinary card",
})
if err != nil {
t.Fatalf("SendFinalCard: %v", err)
}
if _, ok := ts.lastCreateBody["privateData"]; ok {
t.Fatal("privateData must be absent for a non-allowlisted user")
}
}
func TestCardSendGroupAddsDebugPrivateDataOnlyForAsker(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest(
"client-id", "client-secret", "card-template-123",
ts.Client(), ts.URL()+"/token", ts.URL()+"/create",
WithCardDebugUserID("user-debug"),
)
if err != nil {
t.Fatal(err)
}
_, err = sender.SendFinalCard(context.Background(), CardRequest{
ConversationType: core.ConversationGroup,
ExternalUserID: " user-debug ",
ExternalChatID: "group-789",
Content: "group debug card",
})
if err != nil {
t.Fatalf("SendFinalCard: %v", err)
}
privateData, ok := ts.lastCreateBody["privateData"].(map[string]any)
if !ok {
t.Fatal("privateData missing or wrong type")
}
if _, ok := privateData["user-debug"]; !ok {
t.Fatal("group asker's privateData missing")
}
if len(privateData) != 1 {
t.Fatalf("privateData has %d users, want 1", len(privateData))
}
}
// ---------------------------------------------------------------------------
// Test: group conversation full payload
// ---------------------------------------------------------------------------
+190
View File
@@ -0,0 +1,190 @@
package dingtalk
import (
"context"
"errors"
"strings"
sdkcard "github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
feedbackdomain "gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
)
const (
ActionNativeFeedback = "sys_action_like_component"
ActionFeedbackUp = "dobotai_feedback_up"
ActionFeedbackDown = "dobotai_feedback_down"
ActionReasonIncorrect = "dobotai_reason_incorrect"
ActionReasonNotResolved = "dobotai_reason_not_resolved"
ActionReasonIrrelevant = "dobotai_reason_irrelevant"
ActionReasonUnclear = "dobotai_reason_unclear"
ActionReasonOutdated = "dobotai_reason_outdated"
ActionReasonOther = "dobotai_reason_other"
ActionReasonContentMismatch = "dobotai_reason_content_mismatch"
ActionReasonContentIncomplete = "dobotai_reason_content_incomplete"
ActionReasonIllogical = "dobotai_reason_illogical_expression"
ActionReasonFactualError = "dobotai_reason_factual_error"
ActionReasonSensitiveContent = "dobotai_reason_sensitive_content"
ActionReasonAnswerNotWanted = "dobotai_reason_answer_not_wanted"
)
var nativeReasonByTag = map[string]string{
"1": feedbackdomain.ReasonContentMismatch,
"2": feedbackdomain.ReasonContentIncomplete,
"3": feedbackdomain.ReasonIllogical,
"4": feedbackdomain.ReasonFactualError,
"5": feedbackdomain.ReasonSensitiveContent,
"6": feedbackdomain.ReasonAnswerNotWanted,
}
type feedbackService interface {
Apply(context.Context, feedbackdomain.Command) (feedbackdomain.Result, error)
}
type CardFeedbackHandler struct {
service feedbackService
}
func NewCardFeedbackHandler(service feedbackService) (*CardFeedbackHandler, error) {
if service == nil {
return nil, errors.New("dingtalk card feedback: service is required")
}
return &CardFeedbackHandler{service: service}, nil
}
func (h *CardFeedbackHandler) HandleSDK(ctx context.Context, request *sdkcard.CardRequest) (*sdkcard.CardResponse, error) {
if request == nil {
return nil, errors.New("dingtalk card feedback: request is nil")
}
value, reason, recognized := feedbackAction(
request.CardActionData.CardPrivateData.ActionIdList,
request.CardActionData.CardPrivateData.Params,
)
if !recognized {
return &sdkcard.CardResponse{}, nil
}
_, err := h.service.Apply(ctx, feedbackdomain.Command{
ExternalActorID: request.UserId,
OutTrackID: request.OutTrackId,
Value: value,
ReasonCode: reason,
Source: feedbackdomain.SourceDingTalkCard,
})
if err != nil {
return nil, err
}
showReasons := "false"
message := "感谢反馈,我们会继续改进"
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{
"feedbackSelected": value,
"feedbackReasonSelected": reason,
"showFeedbackReasons": showReasons,
"feedbackMessage": message,
}},
}, nil
}
func feedbackAction(actions []string, params map[string]any) (string, string, bool) {
value, reason, matches := "", "", 0
for _, action := range actions {
candidateValue, candidateReason, ok := oneFeedbackAction(action)
if action == ActionNativeFeedback {
candidateValue, candidateReason, ok = nativeFeedbackAction(params)
}
if !ok {
continue
}
matches++
value, reason = candidateValue, candidateReason
}
return value, reason, matches == 1
}
func nativeFeedbackAction(params map[string]any) (string, string, bool) {
key, keyOK := params["variableKey"].(string)
if !keyOK || !strings.HasPrefix(key, "node_") {
return "", "", false
}
if nativeVariableKey(key, "_likeOrDislike") {
value, ok := params["variableValue"].(string)
if !ok {
return "", "", false
}
switch value {
case "like":
return feedbackdomain.ValueUp, feedbackdomain.ReasonEmpty, true
case "dislike":
return feedbackdomain.ValueDown, feedbackdomain.ReasonEmpty, true
default:
return "", "", false
}
}
if nativeVariableKey(key, "_dislikeTags") {
values, ok := params["variableValue"].([]any)
if !ok || len(values) != 1 {
return "", "", false
}
tag, ok := values[0].(string)
if !ok {
return "", "", false
}
reason, ok := nativeReasonByTag[tag]
if !ok {
return "", "", false
}
return feedbackdomain.ValueDown, reason, true
}
return "", "", false
}
func nativeVariableKey(key, suffix string) bool {
if !strings.HasPrefix(key, "node_") || !strings.HasSuffix(key, suffix) {
return false
}
return strings.TrimSuffix(strings.TrimPrefix(key, "node_"), suffix) != ""
}
func oneFeedbackAction(action string) (string, string, bool) {
switch action {
case ActionFeedbackUp:
return feedbackdomain.ValueUp, feedbackdomain.ReasonEmpty, true
case ActionFeedbackDown:
return feedbackdomain.ValueDown, feedbackdomain.ReasonEmpty, true
case ActionReasonIncorrect:
return feedbackdomain.ValueDown, feedbackdomain.ReasonIncorrect, true
case ActionReasonNotResolved:
return feedbackdomain.ValueDown, feedbackdomain.ReasonNotResolved, true
case ActionReasonIrrelevant:
return feedbackdomain.ValueDown, feedbackdomain.ReasonIrrelevant, true
case ActionReasonUnclear:
return feedbackdomain.ValueDown, feedbackdomain.ReasonUnclear, true
case ActionReasonOutdated:
return feedbackdomain.ValueDown, feedbackdomain.ReasonOutdated, true
case ActionReasonOther:
return feedbackdomain.ValueDown, feedbackdomain.ReasonOther, true
case ActionReasonContentMismatch:
return feedbackdomain.ValueDown, feedbackdomain.ReasonContentMismatch, true
case ActionReasonContentIncomplete:
return feedbackdomain.ValueDown, feedbackdomain.ReasonContentIncomplete, true
case ActionReasonIllogical:
return feedbackdomain.ValueDown, feedbackdomain.ReasonIllogical, true
case ActionReasonFactualError:
return feedbackdomain.ValueDown, feedbackdomain.ReasonFactualError, true
case ActionReasonSensitiveContent:
return feedbackdomain.ValueDown, feedbackdomain.ReasonSensitiveContent, true
case ActionReasonAnswerNotWanted:
return feedbackdomain.ValueDown, feedbackdomain.ReasonAnswerNotWanted, true
default:
return "", "", false
}
}
+371
View File
@@ -0,0 +1,371 @@
package dingtalk
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
sdkcard "github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/payload"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/utils"
feedbackdomain "gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
)
type fakeFeedbackService struct {
calls int
command feedbackdomain.Command
result feedbackdomain.Result
err error
}
func (f *fakeFeedbackService) Apply(_ context.Context, command feedbackdomain.Command) (feedbackdomain.Result, error) {
f.calls++
f.command = command
return f.result, f.err
}
func cardRequest(action string) *sdkcard.CardRequest {
return &sdkcard.CardRequest{
OutTrackId: "track-1",
UserId: "user-1",
CorpId: "must-not-be-stored",
Content: "must-not-be-stored",
Extension: "must-not-be-stored",
CardActionData: sdkcard.PrivateCardActionData{CardPrivateData: sdkcard.CardPrivateData{
ActionIdList: []string{action},
Params: map[string]any{
"tenant_key": "attacker", "turn_id": "attacker", "user_id": "attacker", "token": "attacker",
},
}},
}
}
func TestCardFeedbackMapsNativeLike(t *testing.T) {
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: feedbackdomain.ValueUp}}
handler, err := NewCardFeedbackHandler(service)
if err != nil {
t.Fatal(err)
}
request := cardRequest(ActionNativeFeedback)
request.CardActionData.CardPrivateData.Params = map[string]any{
"variableKey": "node_ocmrjfmlsra_likeOrDislike",
"variableValue": "like",
}
response, err := handler.HandleSDK(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if service.calls != 1 || service.command.Value != feedbackdomain.ValueUp || service.command.ReasonCode != feedbackdomain.ReasonEmpty {
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
}
if response == nil || response.UserPrivateData == nil || response.UserPrivateData.CardParamMap["feedbackSelected"] != feedbackdomain.ValueUp {
t.Fatalf("response=%+v", response)
}
}
func TestCardFeedbackMapsNativeDislike(t *testing.T) {
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: feedbackdomain.ValueDown}}
handler, err := NewCardFeedbackHandler(service)
if err != nil {
t.Fatal(err)
}
request := cardRequest(ActionNativeFeedback)
request.CardActionData.CardPrivateData.Params = map[string]any{
"variableKey": "node_ocmrjfmlsra_likeOrDislike",
"variableValue": "dislike",
}
response, err := handler.HandleSDK(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if service.calls != 1 || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != feedbackdomain.ReasonEmpty {
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
}
if response == nil || response.UserPrivateData == nil || response.UserPrivateData.CardParamMap["showFeedbackReasons"] != "true" {
t.Fatalf("response=%+v", response)
}
}
func TestCardFeedbackMapsNativeReasons(t *testing.T) {
tests := []struct {
tag string
reason string
}{
{"1", feedbackdomain.ReasonContentMismatch},
{"2", feedbackdomain.ReasonContentIncomplete},
{"3", feedbackdomain.ReasonIllogical},
{"4", feedbackdomain.ReasonFactualError},
{"5", feedbackdomain.ReasonSensitiveContent},
{"6", feedbackdomain.ReasonAnswerNotWanted},
}
for _, tc := range tests {
t.Run(tc.tag, func(t *testing.T) {
service := &fakeFeedbackService{result: feedbackdomain.Result{Changed: true, Value: feedbackdomain.ValueDown, ReasonCode: tc.reason}}
handler, err := NewCardFeedbackHandler(service)
if err != nil {
t.Fatal(err)
}
request := cardRequest(ActionNativeFeedback)
request.CardActionData.CardPrivateData.Params = map[string]any{
"variableKey": "node_ocmrjfmlsra_dislikeTags",
"variableValue": []any{tc.tag},
}
response, err := handler.HandleSDK(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if service.calls != 1 || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != tc.reason {
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
}
if response == nil || response.UserPrivateData == nil || response.UserPrivateData.CardParamMap["feedbackReasonSelected"] != tc.reason {
t.Fatalf("response=%+v", response)
}
})
}
}
func TestCardFeedbackRejectsMalformedNativeEvents(t *testing.T) {
tests := []struct {
name string
actions []string
params map[string]any
}{
{name: "empty node ID", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node__likeOrDislike", "variableValue": "like"}},
{name: "unknown suffix", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_other", "variableValue": "like"}},
{name: "unknown vote", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_likeOrDislike", "variableValue": "LIKE"}},
{name: "wrong vote type", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_likeOrDislike", "variableValue": []any{"like"}}},
{name: "empty tags", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{}}},
{name: "multiple tags", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{"1", "2"}}},
{name: "numeric tag", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{1}}},
{name: "unknown tag", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{"7"}}},
{name: "ambiguous actions", actions: []string{ActionNativeFeedback, ActionFeedbackUp}, params: map[string]any{"variableKey": "node_x_likeOrDislike", "variableValue": "like"}},
{name: "nil params", actions: []string{ActionNativeFeedback}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
service := &fakeFeedbackService{}
handler, _ := NewCardFeedbackHandler(service)
request := cardRequest("unknown")
request.CardActionData.CardPrivateData.ActionIdList = tc.actions
request.CardActionData.CardPrivateData.Params = tc.params
response, err := handler.HandleSDK(context.Background(), request)
if err != nil || response == nil || service.calls != 0 {
t.Fatalf("response=%+v error=%v calls=%d", response, err, service.calls)
}
if response.CardUpdateOptions != nil || response.UserPrivateData != nil {
t.Fatalf("safe ACK must not update card: %+v", response)
}
})
}
}
func TestCardFeedbackMapsCustomExactReasons(t *testing.T) {
tests := []struct {
action string
reason string
}{
{ActionReasonContentMismatch, feedbackdomain.ReasonContentMismatch},
{ActionReasonContentIncomplete, feedbackdomain.ReasonContentIncomplete},
{ActionReasonIllogical, feedbackdomain.ReasonIllogical},
{ActionReasonFactualError, feedbackdomain.ReasonFactualError},
{ActionReasonSensitiveContent, feedbackdomain.ReasonSensitiveContent},
{ActionReasonAnswerNotWanted, feedbackdomain.ReasonAnswerNotWanted},
}
for _, tc := range tests {
t.Run(tc.action, func(t *testing.T) {
service := &fakeFeedbackService{result: feedbackdomain.Result{Changed: true, Value: feedbackdomain.ValueDown, ReasonCode: tc.reason}}
handler, _ := NewCardFeedbackHandler(service)
response, err := handler.HandleSDK(context.Background(), cardRequest(tc.action))
if err != nil {
t.Fatal(err)
}
if service.calls != 1 || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != tc.reason {
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
}
if response == nil || response.UserPrivateData == nil || response.UserPrivateData.CardParamMap["feedbackReasonSelected"] != tc.reason {
t.Fatalf("response=%+v", response)
}
})
}
}
func TestCardFeedbackMapsWhitelistedActions(t *testing.T) {
tests := []struct {
action string
value string
reason string
showReasons string
message string
}{
{ActionFeedbackUp, feedbackdomain.ValueUp, "", "false", "感谢反馈"},
{ActionFeedbackDown, feedbackdomain.ValueDown, "", "true", "请选择未解决原因"},
{ActionReasonIncorrect, feedbackdomain.ValueDown, feedbackdomain.ReasonIncorrect, "false", "感谢反馈,我们会继续改进"},
{ActionReasonNotResolved, feedbackdomain.ValueDown, feedbackdomain.ReasonNotResolved, "false", "感谢反馈,我们会继续改进"},
{ActionReasonIrrelevant, feedbackdomain.ValueDown, feedbackdomain.ReasonIrrelevant, "false", "感谢反馈,我们会继续改进"},
{ActionReasonUnclear, feedbackdomain.ValueDown, feedbackdomain.ReasonUnclear, "false", "感谢反馈,我们会继续改进"},
{ActionReasonOutdated, feedbackdomain.ValueDown, feedbackdomain.ReasonOutdated, "false", "感谢反馈,我们会继续改进"},
{ActionReasonOther, feedbackdomain.ValueDown, feedbackdomain.ReasonOther, "false", "感谢反馈,我们会继续改进"},
}
for _, tc := range tests {
t.Run(tc.action, func(t *testing.T) {
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: tc.value, ReasonCode: tc.reason}}
handler, err := NewCardFeedbackHandler(service)
if err != nil {
t.Fatal(err)
}
response, err := handler.HandleSDK(context.Background(), cardRequest(tc.action))
if err != nil {
t.Fatal(err)
}
if service.calls != 1 {
t.Fatalf("calls=%d", service.calls)
}
if service.command.ExternalActorID != "user-1" || service.command.OutTrackID != "track-1" || service.command.Value != tc.value || service.command.ReasonCode != tc.reason || service.command.Source != feedbackdomain.SourceDingTalkCard {
t.Fatalf("command=%+v", service.command)
}
if response == nil || response.CardUpdateOptions == nil || !response.CardUpdateOptions.UpdatePrivateDataByKey || response.CardUpdateOptions.UpdateCardDataByKey || response.CardData != nil || response.UserPrivateData == nil {
t.Fatalf("response=%+v", response)
}
params := response.UserPrivateData.CardParamMap
if params["feedbackSelected"] != tc.value || params["feedbackReasonSelected"] != tc.reason || params["showFeedbackReasons"] != tc.showReasons || params["feedbackMessage"] != tc.message {
t.Fatalf("params=%+v", params)
}
})
}
}
func TestCardFeedbackUnknownOrAmbiguousActionIsSafeAck(t *testing.T) {
for _, actions := range [][]string{{"unknown"}, {ActionFeedbackUp, ActionFeedbackDown}, nil} {
service := &fakeFeedbackService{}
handler, _ := NewCardFeedbackHandler(service)
request := cardRequest("unknown")
request.CardActionData.CardPrivateData.ActionIdList = actions
response, err := handler.HandleSDK(context.Background(), request)
if err != nil || response == nil || service.calls != 0 {
t.Fatalf("actions=%v response=%+v error=%v calls=%d", actions, response, err, service.calls)
}
if response.CardUpdateOptions != nil || response.UserPrivateData != nil {
t.Fatalf("safe ACK must not update card: %+v", response)
}
}
}
func TestCardFeedbackValidationAndServiceError(t *testing.T) {
if _, err := NewCardFeedbackHandler(nil); err == nil {
t.Fatal("nil service must fail")
}
serviceErr := errors.New("store failed")
service := &fakeFeedbackService{err: serviceErr}
handler, _ := NewCardFeedbackHandler(service)
if _, err := handler.HandleSDK(context.Background(), nil); err == nil {
t.Fatal("nil request must fail")
}
response, err := handler.HandleSDK(context.Background(), cardRequest(ActionFeedbackUp))
if !errors.Is(err, serviceErr) || response != nil || service.calls != 1 {
t.Fatalf("response=%+v error=%v calls=%d", response, err, service.calls)
}
}
func TestStreamClientRegistersOptionalCardFeedbackRouter(t *testing.T) {
config := StreamConfig{ClientID: "id", ClientSecret: "secret", TenantKey: "tenant"}
chatHandler := mustTestHandler(t)
feedbackHandler, _ := NewCardFeedbackHandler(&fakeFeedbackService{})
client, err := NewStreamClient(config, chatHandler, feedbackHandler)
if err != nil {
t.Fatal(err)
}
if _, err := client.inner.GetHandler(utils.SubscriptionTypeKCallback, payload.CardInstanceCallbackTopic); err != nil {
t.Fatalf("card callback router not registered: %v", err)
}
withoutFeedback, err := NewStreamClient(config, chatHandler)
if err != nil {
t.Fatal(err)
}
if _, err := withoutFeedback.inner.GetHandler(utils.SubscriptionTypeKCallback, payload.CardInstanceCallbackTopic); err == nil {
t.Fatal("card callback router must remain optional for old callers")
}
if _, err := NewStreamClient(config, chatHandler, nil); err == nil {
t.Fatal("explicit nil feedback handler must fail")
}
if _, err := NewStreamClient(config, chatHandler, feedbackHandler, feedbackHandler); err == nil {
t.Fatal("multiple feedback handlers must fail")
}
}
func TestStreamCardRouterHandlesNativeSDKDataFrameEnvelope(t *testing.T) {
service := &fakeFeedbackService{result: feedbackdomain.Result{Changed: true, Value: feedbackdomain.ValueDown, ReasonCode: feedbackdomain.ReasonContentMismatch}}
feedbackHandler, _ := NewCardFeedbackHandler(service)
client, err := NewStreamClient(
StreamConfig{ClientID: "id", ClientSecret: "secret", TenantKey: "tenant"},
mustTestHandler(t), feedbackHandler,
)
if err != nil {
t.Fatal(err)
}
router, err := client.inner.GetHandler(utils.SubscriptionTypeKCallback, payload.CardInstanceCallbackTopic)
if err != nil {
t.Fatal(err)
}
actionData, _ := json.Marshal(map[string]any{"cardPrivateData": map[string]any{
"actionIds": []string{ActionNativeFeedback},
"params": map[string]any{
"variableKey": "node_ocmrjfmlsra_dislikeTags",
"variableValue": []string{"1"},
},
}})
outer, _ := json.Marshal(map[string]any{
"outTrackId": "track-native-frame", "userId": "user-native-frame", "content": string(actionData),
"corpId": "must-not-be-used", "extension": "must-not-be-used",
})
frameResponse, err := router(context.Background(), &payload.DataFrame{Data: string(outer)})
if err != nil {
t.Fatal(err)
}
if frameResponse == nil || frameResponse.Code != payload.DataFrameResponseStatusCodeKOK {
t.Fatalf("frame response=%+v", frameResponse)
}
if service.calls != 1 || service.command.ExternalActorID != "user-native-frame" || service.command.OutTrackID != "track-native-frame" || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != feedbackdomain.ReasonContentMismatch {
t.Fatalf("service calls=%d command=%+v", service.calls, service.command)
}
}
func TestStreamCardRouterHandlesRealSDKDataFrameEnvelope(t *testing.T) {
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: feedbackdomain.ValueDown, ReasonCode: feedbackdomain.ReasonNotResolved}}
feedbackHandler, _ := NewCardFeedbackHandler(service)
client, err := NewStreamClient(
StreamConfig{ClientID: "id", ClientSecret: "secret", TenantKey: "tenant"},
mustTestHandler(t), feedbackHandler,
)
if err != nil {
t.Fatal(err)
}
router, err := client.inner.GetHandler(utils.SubscriptionTypeKCallback, payload.CardInstanceCallbackTopic)
if err != nil {
t.Fatal(err)
}
actionData, _ := json.Marshal(map[string]any{"cardPrivateData": map[string]any{
"actionIds": []string{ActionReasonNotResolved},
"params": map[string]any{"tenant_key": "attacker", "token": "attacker"},
}})
outer, _ := json.Marshal(map[string]any{
"outTrackId": "track-frame", "userId": "user-frame", "content": string(actionData),
"corpId": "must-not-be-used", "extension": "must-not-be-used",
})
frameResponse, err := router(context.Background(), &payload.DataFrame{Data: string(outer)})
if err != nil {
t.Fatal(err)
}
if frameResponse == nil || frameResponse.Code != payload.DataFrameResponseStatusCodeKOK {
t.Fatalf("frame response=%+v", frameResponse)
}
if service.calls != 1 || service.command.ExternalActorID != "user-frame" || service.command.OutTrackID != "track-frame" || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != feedbackdomain.ReasonNotResolved {
t.Fatalf("service calls=%d command=%+v", service.calls, service.command)
}
if strings.Contains(frameResponse.Data, "attacker") || !strings.Contains(frameResponse.Data, "feedbackReasonSelected") {
t.Fatalf("unsafe or incomplete frame response: %s", frameResponse.Data)
}
}
+13 -1
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"strings"
sdkcard "github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
dtsdk "github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
)
@@ -232,7 +233,7 @@ type StreamClient struct {
// 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.
func NewStreamClient(config StreamConfig, handler *Handler) (*StreamClient, error) {
func NewStreamClient(config StreamConfig, handler *Handler, feedbackHandlers ...*CardFeedbackHandler) (*StreamClient, error) {
if err := config.Validate(); err != nil {
return nil, err
}
@@ -242,6 +243,12 @@ func NewStreamClient(config StreamConfig, handler *Handler) (*StreamClient, erro
if handler == nil {
return nil, errors.New("dingtalk stream: handler is required")
}
if len(feedbackHandlers) > 1 {
return nil, errors.New("dingtalk stream: at most one card feedback handler is allowed")
}
if len(feedbackHandlers) == 1 && feedbackHandlers[0] == nil {
return nil, errors.New("dingtalk stream: card feedback handler is nil")
}
cred := dtsdk.NewAppCredentialConfig(strings.TrimSpace(config.ClientID), strings.TrimSpace(config.ClientSecret))
// Keep the SDK default AutoReconnect=true so runtime disconnects
// reconnect automatically; Close flips it off before closing.
@@ -258,6 +265,11 @@ func NewStreamClient(config StreamConfig, handler *Handler) (*StreamClient, erro
}
return nil, nil
})
if len(feedbackHandlers) == 1 {
inner.RegisterCardCallbackRouter(func(ctx context.Context, request *sdkcard.CardRequest) (*sdkcard.CardResponse, error) {
return feedbackHandlers[0].HandleSDK(ctx, request)
})
}
return &StreamClient{inner: inner}, nil
}
+126
View File
@@ -0,0 +1,126 @@
package feedback
import (
"context"
"crypto/rand"
"errors"
"fmt"
"strings"
)
// Service handles answer feedback domain logic. It validates incoming
// commands, normalizes fields, generates cryptographically random IDs,
// and delegates persistence to a Repository.
type Service struct {
tenantKey string
channel string
repo Repository
gen IDGenerator
}
// NewService creates a feedback Service. It rejects empty tenant, empty
// channel, and nil repo. An optional IDGenerator can be provided for
// deterministic testing; if nil, a crypto/rand UUIDv4 generator is used.
func NewService(tenantKey, channel string, repo Repository, gen ...IDGenerator) (*Service, error) {
if strings.TrimSpace(tenantKey) == "" {
return nil, errors.New("tenant_key must not be empty")
}
if strings.TrimSpace(channel) == "" {
return nil, errors.New("channel must not be empty")
}
if repo == nil {
return nil, errors.New("repository must not be nil")
}
if len(gen) > 1 {
return nil, errors.New("at most one IDGenerator allowed")
}
var idGen IDGenerator
if len(gen) == 1 {
idGen = gen[0]
} else {
idGen = &defaultIDGenerator{}
}
if idGen == nil {
return nil, errors.New("IDGenerator must not be nil")
}
return &Service{
tenantKey: strings.TrimSpace(tenantKey),
channel: strings.TrimSpace(channel),
repo: repo,
gen: idGen,
}, nil
}
// defaultIDGenerator produces UUIDv4-style 128-bit random IDs.
type defaultIDGenerator struct{}
func (g *defaultIDGenerator) Generate() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 8-9-a-b
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
// Apply validates and processes a feedback Command. It trims inputs,
// validates allowed values, normalizes reason codes, generates IDs,
// and delegates to the Repository.
func (s *Service) Apply(ctx context.Context, cmd Command) (Result, error) {
cmd.ExternalActorID = strings.TrimSpace(cmd.ExternalActorID)
cmd.OutTrackID = strings.TrimSpace(cmd.OutTrackID)
cmd.Value = strings.TrimSpace(cmd.Value)
cmd.ReasonCode = strings.TrimSpace(cmd.ReasonCode)
cmd.Source = strings.TrimSpace(cmd.Source)
if cmd.ExternalActorID == "" || len(cmd.ExternalActorID) > 256 {
return Result{}, fmt.Errorf("%w: external actor ID", ErrInvalidCommand)
}
if cmd.OutTrackID == "" || len(cmd.OutTrackID) > 256 {
return Result{}, fmt.Errorf("%w: outTrackID", ErrInvalidCommand)
}
if cmd.Value != ValueUp && cmd.Value != ValueDown {
return Result{}, fmt.Errorf("%w: value", ErrInvalidCommand)
}
if cmd.Source != SourceDingTalkCard {
return Result{}, fmt.Errorf("%w: source", ErrInvalidCommand)
}
if cmd.Value == ValueUp {
cmd.ReasonCode = ReasonEmpty
} else if !validReason(cmd.ReasonCode) {
return Result{}, fmt.Errorf("%w: reason code", ErrInvalidCommand)
}
feedbackID, err := s.gen.Generate()
if err != nil {
return Result{}, fmt.Errorf("generate feedback ID: %w", err)
}
if strings.TrimSpace(feedbackID) == "" {
return Result{}, errors.New("generate feedback ID: empty ID")
}
actorID, err := s.gen.Generate()
if err != nil {
return Result{}, fmt.Errorf("generate actor ID: %w", err)
}
if strings.TrimSpace(actorID) == "" {
return Result{}, errors.New("generate actor ID: empty ID")
}
return s.repo.RecordFeedback(ctx, Record{
ID: feedbackID, TenantKey: s.tenantKey, Channel: s.channel, ActorID: actorID,
ExternalActorID: cmd.ExternalActorID, OutTrackID: cmd.OutTrackID,
Value: cmd.Value, ReasonCode: cmd.ReasonCode, Source: cmd.Source,
})
}
func validReason(reason string) bool {
switch reason {
case ReasonEmpty, ReasonIncorrect, ReasonNotResolved, ReasonIrrelevant, ReasonUnclear, ReasonOutdated, ReasonOther,
ReasonContentMismatch, ReasonContentIncomplete, ReasonIllogical, ReasonFactualError, ReasonSensitiveContent, ReasonAnswerNotWanted:
return true
default:
return false
}
}
+191
View File
@@ -0,0 +1,191 @@
package feedback_test
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
)
type stubRepo struct {
calls int
lastRecord feedback.Record
result feedback.Result
err error
}
func (s *stubRepo) RecordFeedback(_ context.Context, record feedback.Record) (feedback.Result, error) {
s.calls++
s.lastRecord = record
return s.result, s.err
}
type sequenceGenerator struct {
ids []string
errs map[int]error
calls int
}
func (g *sequenceGenerator) Generate() (string, error) {
index := g.calls
g.calls++
if err := g.errs[index]; err != nil {
return "", err
}
if index >= len(g.ids) {
return "", errors.New("no id configured")
}
return g.ids[index], nil
}
func TestNewServiceValidation(t *testing.T) {
repo := &stubRepo{}
gen := &sequenceGenerator{ids: []string{"a", "b"}, errs: map[int]error{}}
for _, tc := range []struct {
name string
tenant string
channel string
repo feedback.Repository
gens []feedback.IDGenerator
}{
{name: "empty tenant", channel: "dingtalk", repo: repo},
{name: "empty channel", tenant: "tenant", repo: repo},
{name: "nil repository", tenant: "tenant", channel: "dingtalk"},
{name: "nil generator", tenant: "tenant", channel: "dingtalk", repo: repo, gens: []feedback.IDGenerator{nil}},
{name: "too many generators", tenant: "tenant", channel: "dingtalk", repo: repo, gens: []feedback.IDGenerator{gen, gen}},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := feedback.NewService(tc.tenant, tc.channel, tc.repo, tc.gens...); err == nil {
t.Fatal("expected constructor error")
}
})
}
}
func TestApplyUpUsesServerScopeTrimsAndClearsReason(t *testing.T) {
repo := &stubRepo{result: feedback.Result{Created: true, Value: feedback.ValueUp}}
gen := &sequenceGenerator{ids: []string{"feedback-id", "actor-id"}, errs: map[int]error{}}
service, err := feedback.NewService(" tenant-a ", " dingtalk ", repo, gen)
if err != nil {
t.Fatal(err)
}
result, err := service.Apply(context.Background(), feedback.Command{
ExternalActorID: " user-a ", OutTrackID: " track-a ", Value: feedback.ValueUp,
ReasonCode: feedback.ReasonIncorrect, Source: feedback.SourceDingTalkCard,
})
if err != nil {
t.Fatal(err)
}
if !result.Created || repo.calls != 1 || gen.calls != 2 {
t.Fatalf("result=%+v repo_calls=%d gen_calls=%d", result, repo.calls, gen.calls)
}
got := repo.lastRecord
if got.ID != "feedback-id" || got.ActorID != "actor-id" || got.TenantKey != "tenant-a" || got.Channel != "dingtalk" || got.ExternalActorID != "user-a" || got.OutTrackID != "track-a" || got.Value != feedback.ValueUp || got.ReasonCode != "" || got.Source != feedback.SourceDingTalkCard {
t.Fatalf("record=%+v", got)
}
}
func TestApplyDownAcceptsEveryReasonAndEmpty(t *testing.T) {
reasons := []string{
feedback.ReasonEmpty, feedback.ReasonIncorrect, feedback.ReasonNotResolved,
feedback.ReasonIrrelevant, feedback.ReasonUnclear, feedback.ReasonOutdated, feedback.ReasonOther,
feedback.ReasonContentMismatch, feedback.ReasonContentIncomplete, feedback.ReasonIllogical,
feedback.ReasonFactualError, feedback.ReasonSensitiveContent, feedback.ReasonAnswerNotWanted,
}
for _, reason := range reasons {
t.Run("reason_"+reason, func(t *testing.T) {
repo := &stubRepo{result: feedback.Result{Changed: true, Value: feedback.ValueDown, ReasonCode: reason}}
gen := &sequenceGenerator{ids: []string{"feedback-id", "actor-id"}, errs: map[int]error{}}
service, _ := feedback.NewService("tenant", "dingtalk", repo, gen)
result, err := service.Apply(context.Background(), feedback.Command{
ExternalActorID: "user", OutTrackID: "track", Value: feedback.ValueDown,
ReasonCode: " " + reason + " ", Source: feedback.SourceDingTalkCard,
})
if err != nil {
t.Fatal(err)
}
if repo.lastRecord.ReasonCode != reason || result.ReasonCode != reason {
t.Fatalf("record=%+v result=%+v", repo.lastRecord, result)
}
})
}
}
func TestApplyRejectsInvalidCommandsBeforeGeneratingIDs(t *testing.T) {
tooLong := strings.Repeat("x", 257)
for _, tc := range []struct {
name string
cmd feedback.Command
}{
{name: "empty actor", cmd: feedback.Command{OutTrackID: "track", Value: feedback.ValueUp, Source: feedback.SourceDingTalkCard}},
{name: "empty track", cmd: feedback.Command{ExternalActorID: "user", Value: feedback.ValueUp, Source: feedback.SourceDingTalkCard}},
{name: "long actor", cmd: feedback.Command{ExternalActorID: tooLong, OutTrackID: "track", Value: feedback.ValueUp, Source: feedback.SourceDingTalkCard}},
{name: "long track", cmd: feedback.Command{ExternalActorID: "user", OutTrackID: tooLong, Value: feedback.ValueUp, Source: feedback.SourceDingTalkCard}},
{name: "invalid value", cmd: feedback.Command{ExternalActorID: "user", OutTrackID: "track", Value: "maybe", Source: feedback.SourceDingTalkCard}},
{name: "invalid source", cmd: feedback.Command{ExternalActorID: "user", OutTrackID: "track", Value: feedback.ValueUp, Source: "web"}},
{name: "invalid down reason", cmd: feedback.Command{ExternalActorID: "user", OutTrackID: "track", Value: feedback.ValueDown, ReasonCode: "secret", Source: feedback.SourceDingTalkCard}},
} {
t.Run(tc.name, func(t *testing.T) {
repo := &stubRepo{}
gen := &sequenceGenerator{ids: []string{"feedback-id", "actor-id"}, errs: map[int]error{}}
service, _ := feedback.NewService("tenant", "dingtalk", repo, gen)
if _, err := service.Apply(context.Background(), tc.cmd); !errors.Is(err, feedback.ErrInvalidCommand) {
t.Fatalf("error=%v", err)
}
if repo.calls != 0 || gen.calls != 0 {
t.Fatalf("repo_calls=%d gen_calls=%d", repo.calls, gen.calls)
}
})
}
}
func TestApplyIDFailureNeverCallsRepository(t *testing.T) {
idErr := errors.New("entropy unavailable")
for _, failureIndex := range []int{0, 1} {
t.Run(string(rune('0'+failureIndex)), func(t *testing.T) {
repo := &stubRepo{}
gen := &sequenceGenerator{ids: []string{"feedback-id", "actor-id"}, errs: map[int]error{failureIndex: idErr}}
service, _ := feedback.NewService("tenant", "dingtalk", repo, gen)
_, err := service.Apply(context.Background(), feedback.Command{ExternalActorID: "user", OutTrackID: "track", Value: feedback.ValueUp, Source: feedback.SourceDingTalkCard})
if !errors.Is(err, idErr) || repo.calls != 0 {
t.Fatalf("error=%v repo_calls=%d", err, repo.calls)
}
})
}
}
func TestApplyPassesThroughRepositoryResultAndError(t *testing.T) {
repoErr := errors.New("repository failed")
for _, tc := range []struct {
name string
result feedback.Result
err error
}{
{name: "replayed", result: feedback.Result{Replayed: true, Value: feedback.ValueDown, ReasonCode: feedback.ReasonUnclear}},
{name: "error", err: repoErr},
} {
t.Run(tc.name, func(t *testing.T) {
repo := &stubRepo{result: tc.result, err: tc.err}
gen := &sequenceGenerator{ids: []string{"feedback-id", "actor-id"}, errs: map[int]error{}}
service, _ := feedback.NewService("tenant", "dingtalk", repo, gen)
result, err := service.Apply(context.Background(), feedback.Command{ExternalActorID: "user", OutTrackID: "track", Value: feedback.ValueDown, ReasonCode: feedback.ReasonUnclear, Source: feedback.SourceDingTalkCard})
if !errors.Is(err, tc.err) || result != tc.result {
t.Fatalf("result=%+v error=%v", result, err)
}
})
}
}
func TestRecordCannotBeSerializedIntoExternalJSON(t *testing.T) {
record := feedback.Record{ID: "feedback-id", TenantKey: "tenant", Channel: "dingtalk", ActorID: "actor-id", ExternalActorID: "user", OutTrackID: "provider-track", Value: feedback.ValueDown, ReasonCode: feedback.ReasonOther, Source: feedback.SourceDingTalkCard}
encoded, err := json.Marshal(record)
if err != nil {
t.Fatal(err)
}
if string(encoded) != "{}" {
t.Fatalf("internal record must not be JSON-exposable: %s", encoded)
}
}
+93
View File
@@ -0,0 +1,93 @@
// Package feedback implements the domain logic for answer feedback
// (B2 DingTalk card callback). It validates and normalizes incoming
// feedback commands, generates cryptographically random IDs, and
// delegates persistence to a Repository.
//
// The package is intentionally free of any webhook, token, raw-callback,
// content, or extension columns — those belong to the channel layer.
package feedback
import (
"context"
"errors"
)
var (
ErrInvalidCommand = errors.New("invalid feedback command")
ErrTargetNotFound = errors.New("feedback target not found")
)
// Value constants for feedback.
const (
ValueUp = "up"
ValueDown = "down"
)
// ReasonCode constants for down feedback.
const (
ReasonEmpty = ""
ReasonIncorrect = "incorrect"
ReasonNotResolved = "not_resolved"
ReasonIrrelevant = "irrelevant"
ReasonUnclear = "unclear"
ReasonOutdated = "outdated"
ReasonOther = "other"
ReasonContentMismatch = "content_mismatch"
ReasonContentIncomplete = "content_incomplete"
ReasonIllogical = "illogical_expression"
ReasonFactualError = "factual_error"
ReasonSensitiveContent = "sensitive_content"
ReasonAnswerNotWanted = "answer_not_wanted"
)
// Source constants.
const (
SourceDingTalkCard = "dingtalk_card"
)
// Command represents a feedback action from an external channel.
// Only the caller-provided fields are exposed; tenant, channel, turn,
// and message are fixed by the Service and never accepted from callers.
type Command struct {
ExternalActorID string // external actor identifier (required)
OutTrackID string // outbound tracking/answer identifier (required)
Value string // "up" or "down"
ReasonCode string // optional reason for "down"
Source string // must be "dingtalk_card"
}
// Record is the full domain object passed to the Repository for persistence.
// It contains the fixed tenant/channel, cryptographically random IDs, and
// the normalized command fields.
type Record struct {
ID string `json:"-"`
TenantKey string `json:"-"`
Channel string `json:"-"`
ActorID string `json:"-"`
Value string `json:"-"`
ReasonCode string `json:"-"`
Source string `json:"-"`
ExternalActorID string `json:"-"`
OutTrackID string `json:"-"`
}
// Result reports the outcome of a RecordFeedback call.
// Exactly one of Created, Changed, or Replayed is true.
type Result struct {
Created bool
Changed bool
Replayed bool
Value string
ReasonCode string
}
// Repository persists feedback records.
type Repository interface {
RecordFeedback(ctx context.Context, r Record) (Result, error)
}
// IDGenerator produces unique IDs. The default implementation uses
// crypto/rand to produce UUIDv4-style strings.
type IDGenerator interface {
Generate() (string, error)
}
+30 -3
View File
@@ -19,12 +19,21 @@ const listAuditTurnsSQL = `SELECT
COALESCE(a.external_actor_id,''),COALESCE(rm.actor_name_snapshot,''),
LEFT(t.question,200),LEFT(t.answer,200),
CASE WHEN jsonb_typeof(t.references_json)='array' THEN jsonb_array_length(t.references_json) ELSE 0 END,
COALESCE(am.status,''),COALESCE(am.delivery_method,'')
COALESCE(am.status,''),COALESCE(am.delivery_method,''),
COALESCE(fc.up_count,0),COALESCE(fc.down_count,0),COALESCE(fc.unresolved_count,0)
FROM turns t
LEFT JOIN messages rm ON rm.id=t.request_message_id AND rm.tenant_key=t.tenant_key AND rm.channel=t.channel
LEFT JOIN messages am ON am.id=t.answer_message_id AND am.tenant_key=t.tenant_key AND am.channel=t.channel
LEFT JOIN channel_conversations c ON c.id=rm.conversation_id AND c.tenant_key=t.tenant_key AND c.channel=t.channel
LEFT JOIN channel_actors a ON a.id=rm.actor_id AND a.tenant_key=t.tenant_key AND a.channel=t.channel
LEFT JOIN LATERAL (
SELECT
(COUNT(*) FILTER (WHERE af.value='up'))::int AS up_count,
(COUNT(*) FILTER (WHERE af.value='down'))::int AS down_count,
(COUNT(*) FILTER (WHERE af.value='down' AND af.reason_code=''))::int AS unresolved_count
FROM answer_feedback af
WHERE af.tenant_key=t.tenant_key AND af.channel=t.channel AND af.turn_id=t.id
) fc ON TRUE
WHERE t.tenant_key=$1
AND ($2='' OR t.channel=$2)
AND ($3='' OR t.status=$3)
@@ -38,12 +47,25 @@ const getAuditTurnSQL = `SELECT
COALESCE(a.external_actor_id,''),COALESCE(rm.actor_name_snapshot,''),
t.question,t.answer,t.references_json,t.media_json,
COALESCE(rm.id,''),COALESCE(rm.direction,''),COALESCE(rm.role,''),COALESCE(rm.message_type,''),COALESCE(rm.status,''),COALESCE(rm.actor_name_snapshot,''),rm.created_at,
COALESCE(am.id,''),COALESCE(am.direction,''),COALESCE(am.role,''),COALESCE(am.message_type,''),COALESCE(am.status,''),am.created_at,am.delivered_at,COALESCE(am.delivery_method,''),COALESCE(am.failure_code,'')
COALESCE(am.id,''),COALESCE(am.direction,''),COALESCE(am.role,''),COALESCE(am.message_type,''),COALESCE(am.status,''),am.created_at,am.delivered_at,COALESCE(am.delivery_method,''),COALESCE(am.failure_code,''),
COALESCE(ff.feedback_json,'[]'::jsonb)
FROM turns t
LEFT JOIN messages rm ON rm.id=t.request_message_id AND rm.tenant_key=t.tenant_key AND rm.channel=t.channel
LEFT JOIN messages am ON am.id=t.answer_message_id AND am.tenant_key=t.tenant_key AND am.channel=t.channel
LEFT JOIN channel_conversations c ON c.id=rm.conversation_id AND c.tenant_key=t.tenant_key AND c.channel=t.channel
LEFT JOIN channel_actors a ON a.id=rm.actor_id AND a.tenant_key=t.tenant_key AND a.channel=t.channel
LEFT JOIN LATERAL (
SELECT jsonb_agg(jsonb_build_object(
'actor_external_id',fa.external_actor_id,
'actor_display_name',fa.display_name,
'value',af.value,
'reason_code',af.reason_code,
'updated_at',af.updated_at
) ORDER BY af.updated_at DESC,af.id DESC) AS feedback_json
FROM answer_feedback af
JOIN channel_actors fa ON fa.id=af.actor_id AND fa.tenant_key=af.tenant_key AND fa.channel=af.channel
WHERE af.tenant_key=t.tenant_key AND af.channel=t.channel AND af.turn_id=t.id
) ff ON TRUE
WHERE t.tenant_key=$1 AND t.id=$2`
func (s *Store) ListTurns(ctx context.Context, tenantKey string, query audit.ListQuery) ([]audit.TurnSummary, error) {
@@ -66,6 +88,7 @@ func (s *Store) ListTurns(ctx context.Context, tenantKey string, query audit.Lis
&item.ActorExternalID, &item.ActorDisplayName,
&item.QuestionSummary, &item.AnswerSummary, &item.ReferenceCount,
&item.AnswerDeliveryStatus, &item.AnswerDeliveryMethod,
&item.FeedbackUpCount, &item.FeedbackDownCount, &item.FeedbackUnresolvedCount,
); err != nil {
return nil, fmt.Errorf("scan audit turn summary: %w", err)
}
@@ -81,7 +104,7 @@ func (s *Store) ListTurns(ctx context.Context, tenantKey string, query audit.Lis
func (s *Store) GetTurn(ctx context.Context, tenantKey, turnID string) (audit.TurnDetail, error) {
var detail audit.TurnDetail
var status string
var referencesJSON, mediaJSON []byte
var referencesJSON, mediaJSON, feedbackJSON []byte
var request audit.MessageMeta
var answer audit.MessageMeta
var requestCreated, answerCreated, deliveredAt *time.Time
@@ -92,6 +115,7 @@ func (s *Store) GetTurn(ctx context.Context, tenantKey, turnID string) (audit.Tu
&detail.Question, &detail.Answer, &referencesJSON, &mediaJSON,
&request.ID, &request.Direction, &request.Role, &request.MessageType, &request.Status, &request.ActorName, &requestCreated,
&answer.ID, &answer.Direction, &answer.Role, &answer.MessageType, &answer.Status, &answerCreated, &deliveredAt, &answer.DeliveryMethod, &answer.FailureCode,
&feedbackJSON,
)
if errors.Is(err, pgx.ErrNoRows) {
return audit.TurnDetail{}, audit.ErrNotFound
@@ -117,6 +141,9 @@ func (s *Store) GetTurn(ctx context.Context, tenantKey, turnID string) (audit.Tu
}
}
detail.Media = safeAuditMedia(media)
if err := json.Unmarshal(feedbackJSON, &detail.Feedback); err != nil {
return audit.TurnDetail{}, fmt.Errorf("decode audit feedback: %w", err)
}
if request.ID != "" {
if requestCreated != nil {
@@ -48,6 +48,29 @@ func TestAuditDetailSQLTenantSafeAndExcludesForbiddenData(t *testing.T) {
}
}
func TestAuditFeedbackProjectionIsTenantChannelScoped(t *testing.T) {
listSQL := strings.ToUpper(listAuditTurnsSQL)
for _, want := range []string{
"FROM ANSWER_FEEDBACK AF", "AF.TENANT_KEY=T.TENANT_KEY", "AF.CHANNEL=T.CHANNEL", "AF.TURN_ID=T.ID",
"COUNT(*) FILTER (WHERE AF.VALUE='UP')", "COUNT(*) FILTER (WHERE AF.VALUE='DOWN')",
"AF.VALUE='DOWN' AND AF.REASON_CODE=''",
} {
if !strings.Contains(listSQL, want) {
t.Errorf("list feedback projection missing %q", want)
}
}
detailSQL := strings.ToUpper(getAuditTurnSQL)
for _, want := range []string{
"JSONB_AGG", "FROM ANSWER_FEEDBACK AF", "JOIN CHANNEL_ACTORS FA",
"AF.TENANT_KEY=T.TENANT_KEY", "AF.CHANNEL=T.CHANNEL", "AF.TURN_ID=T.ID",
"FA.TENANT_KEY=AF.TENANT_KEY", "FA.CHANNEL=AF.CHANNEL",
} {
if !strings.Contains(detailSQL, want) {
t.Errorf("detail feedback projection missing %q", want)
}
}
}
func TestSafeAuditReferencesDropContentAndRaw(t *testing.T) {
references := []core.Reference{{
ID: "ref-1", KnowledgeID: "kb-1", KnowledgeTitle: "Manual", Score: 0.9,
+143
View File
@@ -0,0 +1,143 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
)
const findFeedbackAnswerSQL = `SELECT m.id,t.id
FROM messages m
JOIN turns t ON t.id=m.correlation_id AND t.tenant_key=m.tenant_key AND t.channel=m.channel AND t.answer_message_id=m.id
WHERE m.tenant_key=$1 AND m.channel=$2 AND m.provider_message_id=$3
AND m.direction='outbound' AND m.role='assistant' AND m.status='sent' AND m.delivery_method='card'
AND t.status='succeeded'`
const upsertFeedbackActorSQL = `INSERT INTO channel_actors
(id,tenant_key,channel,external_actor_id,actor_type,display_name)
VALUES($1,$2,$3,$4,'user','')
ON CONFLICT (tenant_key,channel,external_actor_id) DO UPDATE SET
updated_at=channel_actors.updated_at
RETURNING id`
const findFeedbackStateSQL = `SELECT id,value,reason_code
FROM answer_feedback
WHERE tenant_key=$1 AND channel=$2 AND answer_message_id=$3 AND actor_id=$4
FOR UPDATE`
const insertFeedbackStateSQL = `INSERT INTO answer_feedback
(id,tenant_key,channel,answer_message_id,turn_id,actor_id,value,reason_code,source)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`
const updateFeedbackStateSQL = `UPDATE answer_feedback
SET value=$1,reason_code=$2,source=$3,updated_at=NOW()
WHERE id=$4 AND tenant_key=$5 AND channel=$6 AND answer_message_id=$7 AND actor_id=$8`
func (s *Store) RecordFeedback(ctx context.Context, record feedback.Record) (feedback.Result, error) {
if err := validateFeedbackRecord(record); err != nil {
return feedback.Result{}, err
}
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return feedback.Result{}, fmt.Errorf("begin feedback transaction: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var answerMessageID, turnID string
err = tx.QueryRow(ctx, findFeedbackAnswerSQL, record.TenantKey, record.Channel, record.OutTrackID).Scan(&answerMessageID, &turnID)
if errors.Is(err, pgx.ErrNoRows) {
return feedback.Result{}, feedback.ErrTargetNotFound
}
if err != nil {
return feedback.Result{}, fmt.Errorf("find feedback answer: %w", err)
}
lockKey := feedbackLockKey(record.TenantKey, record.Channel, answerMessageID, record.ExternalActorID)
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, lockKey); err != nil {
return feedback.Result{}, fmt.Errorf("lock feedback state: %w", err)
}
if err = tx.QueryRow(ctx, upsertFeedbackActorSQL,
record.ActorID, record.TenantKey, record.Channel, record.ExternalActorID,
).Scan(&record.ActorID); err != nil {
return feedback.Result{}, fmt.Errorf("resolve feedback actor: %w", err)
}
var existingID, existingValue, existingReason string
err = tx.QueryRow(ctx, findFeedbackStateSQL,
record.TenantKey, record.Channel, answerMessageID, record.ActorID,
).Scan(&existingID, &existingValue, &existingReason)
if errors.Is(err, pgx.ErrNoRows) {
command, insertErr := tx.Exec(ctx, insertFeedbackStateSQL,
record.ID, record.TenantKey, record.Channel, answerMessageID, turnID,
record.ActorID, record.Value, record.ReasonCode, record.Source,
)
if insertErr != nil {
return feedback.Result{}, fmt.Errorf("insert feedback state: %w", insertErr)
}
if command.RowsAffected() != 1 {
return feedback.Result{}, errors.New("insert feedback state affected unexpected rows")
}
if err = tx.Commit(ctx); err != nil {
return feedback.Result{}, fmt.Errorf("commit feedback create: %w", err)
}
return feedback.Result{Created: true, Value: record.Value, ReasonCode: record.ReasonCode}, nil
}
if err != nil {
return feedback.Result{}, fmt.Errorf("find feedback state: %w", err)
}
if sameFeedbackState(existingValue, existingReason, record.Value, record.ReasonCode) {
if err = tx.Commit(ctx); err != nil {
return feedback.Result{}, fmt.Errorf("commit feedback replay: %w", err)
}
return feedback.Result{Replayed: true, Value: existingValue, ReasonCode: existingReason}, nil
}
command, err := tx.Exec(ctx, updateFeedbackStateSQL,
record.Value, record.ReasonCode, record.Source, existingID,
record.TenantKey, record.Channel, answerMessageID, record.ActorID,
)
if err != nil {
return feedback.Result{}, fmt.Errorf("update feedback state: %w", err)
}
if command.RowsAffected() != 1 {
return feedback.Result{}, errors.New("update feedback state affected unexpected rows")
}
if err = tx.Commit(ctx); err != nil {
return feedback.Result{}, fmt.Errorf("commit feedback change: %w", err)
}
return feedback.Result{Changed: true, Value: record.Value, ReasonCode: record.ReasonCode}, nil
}
func validateFeedbackRecord(record feedback.Record) error {
if record.ID == "" || record.ActorID == "" || record.TenantKey == "" || record.Channel == "" ||
record.ExternalActorID == "" || record.OutTrackID == "" || record.Source != feedback.SourceDingTalkCard {
return feedback.ErrInvalidCommand
}
if record.Value != feedback.ValueUp && record.Value != feedback.ValueDown {
return feedback.ErrInvalidCommand
}
if record.Value == feedback.ValueUp && record.ReasonCode != "" {
return feedback.ErrInvalidCommand
}
return nil
}
func feedbackLockKey(parts ...string) string {
key := ""
for _, part := range parts {
key += fmt.Sprintf("%d:%s|", len(part), part)
}
return key
}
func sameFeedbackState(existingValue, existingReason, value, reason string) bool {
return existingValue == value && existingReason == reason
}
var _ feedback.Repository = (*Store)(nil)
+77
View File
@@ -0,0 +1,77 @@
package postgres
import (
"os"
"strings"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
)
func TestFeedbackAnswerLookupRequiresExactDeliveredCard(t *testing.T) {
sql := strings.ToUpper(findFeedbackAnswerSQL)
for _, fragment := range []string{
"FROM MESSAGES M", "JOIN TURNS T", "T.TENANT_KEY=M.TENANT_KEY", "T.CHANNEL=M.CHANNEL",
"T.ANSWER_MESSAGE_ID=M.ID", "M.TENANT_KEY=$1", "M.CHANNEL=$2", "M.PROVIDER_MESSAGE_ID=$3",
"M.DIRECTION='OUTBOUND'", "M.ROLE='ASSISTANT'", "M.STATUS='SENT'", "M.DELIVERY_METHOD='CARD'",
"T.STATUS='SUCCEEDED'",
} {
if !strings.Contains(sql, fragment) {
t.Errorf("answer lookup missing %q", fragment)
}
}
}
func TestFeedbackActorAndStateSQLAreTenantChannelSafe(t *testing.T) {
actor := strings.ToUpper(upsertFeedbackActorSQL)
for _, fragment := range []string{
"INSERT INTO CHANNEL_ACTORS", "TENANT_KEY", "CHANNEL", "EXTERNAL_ACTOR_ID",
"ON CONFLICT (TENANT_KEY,CHANNEL,EXTERNAL_ACTOR_ID)", "RETURNING ID",
} {
if !strings.Contains(actor, fragment) {
t.Errorf("actor upsert missing %q", fragment)
}
}
lookup := strings.ToUpper(findFeedbackStateSQL)
for _, fragment := range []string{"TENANT_KEY=$1", "CHANNEL=$2", "ANSWER_MESSAGE_ID=$3", "ACTOR_ID=$4", "FOR UPDATE"} {
if !strings.Contains(lookup, fragment) {
t.Errorf("feedback lookup missing %q", fragment)
}
}
update := strings.ToUpper(updateFeedbackStateSQL)
for _, fragment := range []string{"VALUE=$1", "REASON_CODE=$2", "SOURCE=$3", "UPDATED_AT=NOW()", "ID=$4", "TENANT_KEY=$5", "CHANNEL=$6", "ANSWER_MESSAGE_ID=$7", "ACTOR_ID=$8"} {
if !strings.Contains(update, fragment) {
t.Errorf("feedback update missing %q", fragment)
}
}
}
func TestFeedbackStoreUsesShortSerializedTransaction(t *testing.T) {
data, err := os.ReadFile("feedback.go")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, fragment := range []string{"BEGINTX", "PG_ADVISORY_XACT_LOCK", "ROLLBACK", "COMMIT"} {
if !strings.Contains(source, fragment) {
t.Errorf("feedback store missing transaction fragment %q", fragment)
}
}
for _, forbidden := range []string{"SESSIONWEBHOOK", "ACCESS_TOKEN", "RAW_CALLBACK", "RAW_PAYLOAD", "CORP_ID", "EXTENSION"} {
if strings.Contains(source, forbidden) {
t.Errorf("feedback store contains forbidden %q", forbidden)
}
}
}
func TestSameFeedbackState(t *testing.T) {
if !sameFeedbackState(feedback.ValueDown, feedback.ReasonUnclear, feedback.ValueDown, feedback.ReasonUnclear) {
t.Fatal("identical feedback must be replayed")
}
if sameFeedbackState(feedback.ValueDown, feedback.ReasonUnclear, feedback.ValueDown, feedback.ReasonOther) {
t.Fatal("reason change must be a state change")
}
if sameFeedbackState(feedback.ValueDown, "", feedback.ValueUp, "") {
t.Fatal("value change must be a state change")
}
}