feat: handle native DingTalk card feedback

This commit is contained in:
姚章浩
2026-07-21 20:41:12 +08:00
parent fcf4559c9a
commit b8f572cc84
8 changed files with 367 additions and 10 deletions
@@ -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)
}
}
}
+63 -2
View File
@@ -3,6 +3,7 @@ package dingtalk
import (
"context"
"errors"
"strings"
sdkcard "github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
@@ -10,6 +11,7 @@ import (
)
const (
ActionNativeFeedback = "sys_action_like_component"
ActionFeedbackUp = "dobotai_feedback_up"
ActionFeedbackDown = "dobotai_feedback_down"
ActionReasonIncorrect = "dobotai_reason_incorrect"
@@ -20,6 +22,15 @@ const (
ActionReasonOther = "dobotai_reason_other"
)
var nativeReasonByTag = map[string]string{
"1": feedbackdomain.ReasonContentMismatch,
"2": feedbackdomain.ReasonContentIncomplete,
"3": feedbackdomain.ReasonIllogical,
"4": feedbackdomain.ReasonFactualError,
"5": feedbackdomain.ReasonSensitiveContent,
"6": feedbackdomain.ReasonAnswerNotWanted,
}
type feedbackService interface {
Apply(context.Context, feedbackdomain.Command) (feedbackdomain.Result, error)
}
@@ -39,7 +50,10 @@ func (h *CardFeedbackHandler) HandleSDK(ctx context.Context, request *sdkcard.Ca
if request == nil {
return nil, errors.New("dingtalk card feedback: request is nil")
}
value, reason, recognized := feedbackAction(request.CardActionData.CardPrivateData.ActionIdList)
value, reason, recognized := feedbackAction(
request.CardActionData.CardPrivateData.ActionIdList,
request.CardActionData.CardPrivateData.Params,
)
if !recognized {
return &sdkcard.CardResponse{}, nil
}
@@ -74,10 +88,13 @@ func (h *CardFeedbackHandler) HandleSDK(ctx context.Context, request *sdkcard.Ca
}, nil
}
func feedbackAction(actions []string) (string, string, bool) {
func feedbackAction(actions []string, params map[string]any) (string, string, bool) {
value, reason, matches := "", "", 0
for _, action := range actions {
candidateValue, candidateReason, ok := oneFeedbackAction(action)
if action == ActionNativeFeedback {
candidateValue, candidateReason, ok = nativeFeedbackAction(params)
}
if !ok {
continue
}
@@ -87,6 +104,50 @@ func feedbackAction(actions []string) (string, string, bool) {
return value, reason, matches == 1
}
func nativeFeedbackAction(params map[string]any) (string, string, bool) {
key, keyOK := params["variableKey"].(string)
if !keyOK || !strings.HasPrefix(key, "node_") {
return "", "", false
}
if nativeVariableKey(key, "_likeOrDislike") {
value, ok := params["variableValue"].(string)
if !ok {
return "", "", false
}
switch value {
case "like":
return feedbackdomain.ValueUp, feedbackdomain.ReasonEmpty, true
case "dislike":
return feedbackdomain.ValueDown, feedbackdomain.ReasonEmpty, true
default:
return "", "", false
}
}
if nativeVariableKey(key, "_dislikeTags") {
values, ok := params["variableValue"].([]any)
if !ok || len(values) != 1 {
return "", "", false
}
tag, ok := values[0].(string)
if !ok {
return "", "", false
}
reason, ok := nativeReasonByTag[tag]
if !ok {
return "", "", false
}
return feedbackdomain.ValueDown, reason, true
}
return "", "", false
}
func nativeVariableKey(key, suffix string) bool {
if !strings.HasPrefix(key, "node_") || !strings.HasSuffix(key, suffix) {
return false
}
return strings.TrimSuffix(strings.TrimPrefix(key, "node_"), suffix) != ""
}
func oneFeedbackAction(action string) (string, string, bool) {
switch action {
case ActionFeedbackUp:
+156
View File
@@ -43,6 +43,125 @@ func cardRequest(action string) *sdkcard.CardRequest {
}
}
func TestCardFeedbackMapsNativeLike(t *testing.T) {
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: feedbackdomain.ValueUp}}
handler, err := NewCardFeedbackHandler(service)
if err != nil {
t.Fatal(err)
}
request := cardRequest(ActionNativeFeedback)
request.CardActionData.CardPrivateData.Params = map[string]any{
"variableKey": "node_ocmrjfmlsra_likeOrDislike",
"variableValue": "like",
}
response, err := handler.HandleSDK(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if service.calls != 1 || service.command.Value != feedbackdomain.ValueUp || service.command.ReasonCode != feedbackdomain.ReasonEmpty {
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
}
if response == nil || response.UserPrivateData == nil || response.UserPrivateData.CardParamMap["feedbackSelected"] != feedbackdomain.ValueUp {
t.Fatalf("response=%+v", response)
}
}
func TestCardFeedbackMapsNativeDislike(t *testing.T) {
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: feedbackdomain.ValueDown}}
handler, err := NewCardFeedbackHandler(service)
if err != nil {
t.Fatal(err)
}
request := cardRequest(ActionNativeFeedback)
request.CardActionData.CardPrivateData.Params = map[string]any{
"variableKey": "node_ocmrjfmlsra_likeOrDislike",
"variableValue": "dislike",
}
response, err := handler.HandleSDK(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if service.calls != 1 || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != feedbackdomain.ReasonEmpty {
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
}
if response == nil || response.UserPrivateData == nil || response.UserPrivateData.CardParamMap["showFeedbackReasons"] != "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 TestCardFeedbackMapsWhitelistedActions(t *testing.T) {
tests := []struct {
action string
@@ -147,6 +266,43 @@ func TestStreamClientRegistersOptionalCardFeedbackRouter(t *testing.T) {
}
}
func TestStreamCardRouterHandlesNativeSDKDataFrameEnvelope(t *testing.T) {
service := &fakeFeedbackService{result: feedbackdomain.Result{Changed: true, Value: feedbackdomain.ValueDown, ReasonCode: feedbackdomain.ReasonContentMismatch}}
feedbackHandler, _ := NewCardFeedbackHandler(service)
client, err := NewStreamClient(
StreamConfig{ClientID: "id", ClientSecret: "secret", TenantKey: "tenant"},
mustTestHandler(t), feedbackHandler,
)
if err != nil {
t.Fatal(err)
}
router, err := client.inner.GetHandler(utils.SubscriptionTypeKCallback, payload.CardInstanceCallbackTopic)
if err != nil {
t.Fatal(err)
}
actionData, _ := json.Marshal(map[string]any{"cardPrivateData": map[string]any{
"actionIds": []string{ActionNativeFeedback},
"params": map[string]any{
"variableKey": "node_ocmrjfmlsra_dislikeTags",
"variableValue": []string{"1"},
},
}})
outer, _ := json.Marshal(map[string]any{
"outTrackId": "track-native-frame", "userId": "user-native-frame", "content": string(actionData),
"corpId": "must-not-be-used", "extension": "must-not-be-used",
})
frameResponse, err := router(context.Background(), &payload.DataFrame{Data: string(outer)})
if err != nil {
t.Fatal(err)
}
if frameResponse == nil || frameResponse.Code != payload.DataFrameResponseStatusCodeKOK {
t.Fatalf("frame response=%+v", frameResponse)
}
if service.calls != 1 || service.command.ExternalActorID != "user-native-frame" || service.command.OutTrackID != "track-native-frame" || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != feedbackdomain.ReasonContentMismatch {
t.Fatalf("service calls=%d command=%+v", service.calls, service.command)
}
}
func TestStreamCardRouterHandlesRealSDKDataFrameEnvelope(t *testing.T) {
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: feedbackdomain.ValueDown, ReasonCode: feedbackdomain.ReasonNotResolved}}
feedbackHandler, _ := NewCardFeedbackHandler(service)
+2 -1
View File
@@ -117,7 +117,8 @@ func (s *Service) Apply(ctx context.Context, cmd Command) (Result, error) {
func validReason(reason string) bool {
switch reason {
case ReasonEmpty, ReasonIncorrect, ReasonNotResolved, ReasonIrrelevant, ReasonUnclear, ReasonOutdated, ReasonOther:
case ReasonEmpty, ReasonIncorrect, ReasonNotResolved, ReasonIrrelevant, ReasonUnclear, ReasonOutdated, ReasonOther,
ReasonContentMismatch, ReasonContentIncomplete, ReasonIllogical, ReasonFactualError, ReasonSensitiveContent, ReasonAnswerNotWanted:
return true
default:
return false
+2
View File
@@ -92,6 +92,8 @@ 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) {
+13 -7
View File
@@ -25,13 +25,19 @@ const (
// ReasonCode constants for down feedback.
const (
ReasonEmpty = ""
ReasonIncorrect = "incorrect"
ReasonNotResolved = "not_resolved"
ReasonIrrelevant = "irrelevant"
ReasonUnclear = "unclear"
ReasonOutdated = "outdated"
ReasonOther = "other"
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.