From 703406df13db19e9ab930d1290ced331fa71d932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=82=B3=E6=98=B1?= Date: Wed, 29 Jul 2026 22:23:13 +0800 Subject: [PATCH] feat(event): publish typed OA approval schemas --- internal/app/event_personal_oa_test.go | 74 ++++- internal/cli/schema_agent_metadata/index.json | 2 +- internal/cli/schema_agent_metadata_audit.json | 2 +- internal/cli/schema_catalog.json | 4 +- internal/event/personal/output.go | 249 +++++++++++++++-- internal/event/personal/output_test.go | 263 ++++++++++++++---- internal/event/personal/registry_test.go | 92 ++++-- skills/mono/references/products/event.md | 4 +- skills/multi/dingtalk-event/SKILL.md | 6 +- .../dingtalk-event/references/event-oa.md | 52 +++- 10 files changed, 622 insertions(+), 126 deletions(-) diff --git a/internal/app/event_personal_oa_test.go b/internal/app/event_personal_oa_test.go index 0f337956..ddc4c7db 100644 --- a/internal/app/event_personal_oa_test.go +++ b/internal/app/event_personal_oa_test.go @@ -27,14 +27,58 @@ func TestPersonalOAEventListAndSchemaCommands(t *testing.T) { if err := list.Execute(); err != nil { t.Fatalf("event list --category oa error = %v", err) } - for _, eventKey := range []string{ - personal.EventOAApprovalTaskCreated, - personal.EventOAApprovalTaskFinished, - personal.EventOAApprovalTaskRedirected, - personal.EventOAApprovalInstanceStarted, - personal.EventOAApprovalInstanceTerminated, - personal.EventOAApprovalInstanceFinished, - } { + tests := []struct { + eventKey string + properties []string + }{ + { + eventKey: personal.EventOAApprovalTaskCreated, + properties: []string{ + "type", "event_id", "timestamp", "subscribe_id", "process_instance_id", + "process_code", "task_id", "title", "status", "create_time", "event_time", + }, + }, + { + eventKey: personal.EventOAApprovalTaskFinished, + properties: []string{ + "type", "event_id", "timestamp", "subscribe_id", "process_instance_id", + "process_code", "task_id", "title", "status", "result", "create_time", + "finish_time", "event_time", + }, + }, + { + eventKey: personal.EventOAApprovalTaskRedirected, + properties: []string{ + "type", "event_id", "timestamp", "subscribe_id", "process_instance_id", + "process_code", "task_id", "title", "status", "result", "create_time", + "finish_time", "event_time", + }, + }, + { + eventKey: personal.EventOAApprovalInstanceStarted, + properties: []string{ + "type", "event_id", "timestamp", "subscribe_id", "process_instance_id", + "process_code", "title", "status", "create_time", "event_time", + }, + }, + { + eventKey: personal.EventOAApprovalInstanceTerminated, + properties: []string{ + "type", "event_id", "timestamp", "subscribe_id", "process_instance_id", + "process_code", "title", "status", "create_time", "finish_time", "event_time", + }, + }, + { + eventKey: personal.EventOAApprovalInstanceFinished, + properties: []string{ + "type", "event_id", "timestamp", "subscribe_id", "process_instance_id", + "process_code", "title", "status", "result", "create_time", "finish_time", + "event_time", + }, + }, + } + for _, tt := range tests { + eventKey := tt.eventKey if !strings.Contains(listOut.String(), eventKey) { t.Fatalf("OA event list missing %s:\n%s", eventKey, listOut.String()) } @@ -60,12 +104,16 @@ func TestPersonalOAEventListAndSchemaCommands(t *testing.T) { t.Fatalf("schema body for %s = %#v", eventKey, doc["schema"]) } properties, ok := schemaBody["properties"].(map[string]any) - if !ok { - t.Fatalf("schema properties for %s = %#v", eventKey, schemaBody["properties"]) + if !ok || len(properties) != len(tt.properties) { + t.Fatalf("schema properties for %s = %#v, want %d fields", eventKey, schemaBody["properties"], len(tt.properties)) } - payload, ok := properties["payload"].(map[string]any) - if !ok || payload["additionalProperties"] != true { - t.Fatalf("schema payload for %s = %#v, want open object", eventKey, properties["payload"]) + for _, name := range tt.properties { + if _, ok := properties[name].(map[string]any); !ok { + t.Fatalf("schema property %s for %s = %#v", name, eventKey, properties[name]) + } + } + if _, ok := properties["payload"]; ok { + t.Fatalf("schema for %s exposed generic payload: %#v", eventKey, properties) } } if strings.Contains(listOut.String(), personal.EventMention) { diff --git a/internal/cli/schema_agent_metadata/index.json b/internal/cli/schema_agent_metadata/index.json index f4dccb70..7a6daa42 100644 --- a/internal/cli/schema_agent_metadata/index.json +++ b/internal/cli/schema_agent_metadata/index.json @@ -1,6 +1,6 @@ { "version": 1, - "source_hash": "sha256:853478184de2998932c3ce94cab108a1640a96b17a9db2faedcefb169e80e690", + "source_hash": "sha256:c2d5854a1d9b0f6570e2f60283529d84b3f7a1d231cb5f76a0007c479d40cefe", "surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed", "coverage": { "surface_products": 26, diff --git a/internal/cli/schema_agent_metadata_audit.json b/internal/cli/schema_agent_metadata_audit.json index b91f2c50..800d773b 100644 --- a/internal/cli/schema_agent_metadata_audit.json +++ b/internal/cli/schema_agent_metadata_audit.json @@ -1,6 +1,6 @@ { "version": 1, - "source_hash": "sha256:853478184de2998932c3ce94cab108a1640a96b17a9db2faedcefb169e80e690", + "source_hash": "sha256:c2d5854a1d9b0f6570e2f60283529d84b3f7a1d231cb5f76a0007c479d40cefe", "surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed", "source_files": 160, "hint_files": 54, diff --git a/internal/cli/schema_catalog.json b/internal/cli/schema_catalog.json index c726cbe8..ed1f879a 100644 --- a/internal/cli/schema_catalog.json +++ b/internal/cli/schema_catalog.json @@ -1,12 +1,12 @@ { "version": 1, - "source_hash": "sha256:ac05d2673d85f725b952faa7bd91fba6cd9032e0e7ab4708a9dc796ffd5032b8", + "source_hash": "sha256:0f607968472026be03b650cf7ea63e0ed9f7e12137dec2d694f3c62f2e81f837", "surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed", "catalog": { "agent_metadata": { "products_with_metadata": 26, "source": "embedded-skill-metadata", - "source_hash": "sha256:853478184de2998932c3ce94cab108a1640a96b17a9db2faedcefb169e80e690", + "source_hash": "sha256:c2d5854a1d9b0f6570e2f60283529d84b3f7a1d231cb5f76a0007c479d40cefe", "surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed", "surface_products": 26, "surface_tools": 813, diff --git a/internal/event/personal/output.go b/internal/event/personal/output.go index d7918f80..00d81a01 100644 --- a/internal/event/personal/output.go +++ b/internal/event/personal/output.go @@ -121,15 +121,92 @@ type GroupLifecycleEventOutput struct { Payload map[string]any `json:"payload" description:"群生命周期事件业务数据,字段以服务端实际推送为准" additional_properties:"true"` } -// OAEventOutput intentionally keeps the OA payload open until stable approval -// samples and status enums are available. Only top-level transport routing -// metadata is removed; all unknown business fields are preserved. -type OAEventOutput struct { - Type string `json:"type" description:"事件类型,固定为当前 event_key"` - EventID string `json:"event_id" description:"事件 ID,可用于去重"` - Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"` - SubscribeID string `json:"subscribe_id" description:"订阅 ID"` - Payload map[string]any `json:"payload" description:"OA 审批事件业务数据,字段以服务端实际推送为准" additional_properties:"true"` +type OAApprovalTaskCreatedOutput struct { + Type string `json:"type" description:"事件类型,固定为当前 event_key"` + EventID string `json:"event_id" description:"事件 ID,可用于去重"` + Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"` + SubscribeID string `json:"subscribe_id" description:"订阅 ID"` + ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"` + ProcessCode string `json:"process_code" description:"审批流程模板编码"` + TaskID string `json:"task_id" description:"审批任务 ID"` + Title string `json:"title" description:"审批标题"` + Status string `json:"status" description:"审批任务状态"` + CreateTime int64 `json:"create_time" description:"审批任务创建时间" format:"timestamp_ms"` + EventTime int64 `json:"event_time" description:"审批任务事件业务时间" format:"timestamp_ms"` +} + +type OAApprovalTaskFinishedOutput struct { + Type string `json:"type" description:"事件类型,固定为当前 event_key"` + EventID string `json:"event_id" description:"事件 ID,可用于去重"` + Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"` + SubscribeID string `json:"subscribe_id" description:"订阅 ID"` + ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"` + ProcessCode string `json:"process_code" description:"审批流程模板编码"` + TaskID string `json:"task_id" description:"审批任务 ID"` + Title string `json:"title" description:"审批标题"` + Status string `json:"status" description:"审批任务状态"` + Result string `json:"result" description:"审批任务处理结果,值以服务端实际推送为准"` + CreateTime int64 `json:"create_time" description:"审批任务创建时间" format:"timestamp_ms"` + FinishTime int64 `json:"finish_time" description:"审批任务完成时间" format:"timestamp_ms"` + EventTime int64 `json:"event_time" description:"审批任务事件业务时间" format:"timestamp_ms"` +} + +type OAApprovalTaskRedirectedOutput struct { + Type string `json:"type" description:"事件类型,固定为当前 event_key"` + EventID string `json:"event_id" description:"事件 ID,可用于去重"` + Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"` + SubscribeID string `json:"subscribe_id" description:"订阅 ID"` + ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"` + ProcessCode string `json:"process_code" description:"审批流程模板编码"` + TaskID string `json:"task_id" description:"原审批任务 ID"` + Title string `json:"title" description:"审批标题"` + Status string `json:"status" description:"原审批任务状态"` + Result string `json:"result" description:"审批任务转交结果,值以服务端实际推送为准"` + CreateTime int64 `json:"create_time" description:"原审批任务创建时间" format:"timestamp_ms"` + FinishTime int64 `json:"finish_time" description:"原审批任务转交完成时间" format:"timestamp_ms"` + EventTime int64 `json:"event_time" description:"审批任务转交事件业务时间" format:"timestamp_ms"` +} + +type OAApprovalInstanceStartedOutput struct { + Type string `json:"type" description:"事件类型,固定为当前 event_key"` + EventID string `json:"event_id" description:"事件 ID,可用于去重"` + Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"` + SubscribeID string `json:"subscribe_id" description:"订阅 ID"` + ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"` + ProcessCode string `json:"process_code" description:"审批流程模板编码"` + Title string `json:"title" description:"审批标题"` + Status string `json:"status" description:"审批实例状态"` + CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"` + EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"` +} + +type OAApprovalInstanceTerminatedOutput struct { + Type string `json:"type" description:"事件类型,固定为当前 event_key"` + EventID string `json:"event_id" description:"事件 ID,可用于去重"` + Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"` + SubscribeID string `json:"subscribe_id" description:"订阅 ID"` + ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"` + ProcessCode string `json:"process_code" description:"审批流程模板编码"` + Title string `json:"title" description:"审批标题"` + Status string `json:"status" description:"审批实例状态"` + CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"` + FinishTime int64 `json:"finish_time" description:"审批实例终止时间" format:"timestamp_ms"` + EventTime int64 `json:"event_time" description:"审批实例终止事件业务时间" format:"timestamp_ms"` +} + +type OAApprovalInstanceFinishedOutput struct { + Type string `json:"type" description:"事件类型,固定为当前 event_key"` + EventID string `json:"event_id" description:"事件 ID,可用于去重"` + Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"` + SubscribeID string `json:"subscribe_id" description:"订阅 ID"` + ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"` + ProcessCode string `json:"process_code" description:"审批流程模板编码"` + Title string `json:"title" description:"审批标题"` + Status string `json:"status" description:"审批实例状态"` + Result string `json:"result" description:"审批实例处理结果,值以服务端实际推送为准"` + CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"` + FinishTime int64 `json:"finish_time" description:"审批实例完成时间" format:"timestamp_ms"` + EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"` } type GroupMemberEventOutput struct { @@ -241,6 +318,22 @@ type personalGroupMemberRecord struct { OpenDingTalkID string `json:"openDingTalkId"` } +type personalOAApprovalPayload struct { + EventTime int64 `json:"event_time"` + Body personalOAApprovalBody `json:"body"` +} + +type personalOAApprovalBody struct { + ProcessInstanceID string `json:"processInstanceId"` + ProcessCode string `json:"processCode"` + TaskID string `json:"taskId"` + Title string `json:"title"` + Status string `json:"status"` + Result string `json:"result"` + CreateTime int64 `json:"createTime"` + FinishTime int64 `json:"finishTime"` +} + func (b *personalReactionBody) UnmarshalJSON(data []byte) error { // encoding/json otherwise falls back to case-insensitive field matching. // Read this protocol field from a map so only operOpenDingtalkId is accepted. @@ -366,17 +459,7 @@ func ProjectOutput(ev transport.Event) (any, error) { Payload: payload, }, nil case isOAEvent(eventType): - payload, err := decodeConservativePayload(data.Payload) - if err != nil { - return ev, fmt.Errorf("decode personal OA payload: %w", err) - } - return OAEventOutput{ - Type: base.Type, - EventID: base.EventID, - Timestamp: base.Timestamp, - SubscribeID: base.SubscribeID, - Payload: payload, - }, nil + return projectOAApprovalEvent(ev, base, data.Payload) default: return ev, fmt.Errorf("unsupported personal event type %q", eventType) } @@ -512,6 +595,112 @@ func projectGroupMemberEvent(ev transport.Event, base baseEventOutput, raw json. }, nil } +func projectOAApprovalEvent(ev transport.Event, base baseEventOutput, raw json.RawMessage) (any, error) { + var payload personalOAApprovalPayload + if err := decodeRequiredPayload(raw, &payload); err != nil { + return ev, fmt.Errorf("decode personal OA payload: %w", err) + } + if strings.TrimSpace(payload.Body.ProcessInstanceID) == "" { + return ev, fmt.Errorf("decode personal OA payload: processInstanceId is required") + } + if isOAApprovalTaskEvent(base.Type) && strings.TrimSpace(payload.Body.TaskID) == "" { + return ev, fmt.Errorf("decode personal OA payload: taskId is required for %s", base.Type) + } + + switch base.Type { + case EventOAApprovalTaskCreated: + return OAApprovalTaskCreatedOutput{ + Type: base.Type, + EventID: base.EventID, + Timestamp: base.Timestamp, + SubscribeID: base.SubscribeID, + ProcessInstanceID: payload.Body.ProcessInstanceID, + ProcessCode: payload.Body.ProcessCode, + TaskID: payload.Body.TaskID, + Title: payload.Body.Title, + Status: payload.Body.Status, + CreateTime: payload.Body.CreateTime, + EventTime: payload.EventTime, + }, nil + case EventOAApprovalTaskFinished: + return OAApprovalTaskFinishedOutput{ + Type: base.Type, + EventID: base.EventID, + Timestamp: base.Timestamp, + SubscribeID: base.SubscribeID, + ProcessInstanceID: payload.Body.ProcessInstanceID, + ProcessCode: payload.Body.ProcessCode, + TaskID: payload.Body.TaskID, + Title: payload.Body.Title, + Status: payload.Body.Status, + Result: payload.Body.Result, + CreateTime: payload.Body.CreateTime, + FinishTime: payload.Body.FinishTime, + EventTime: payload.EventTime, + }, nil + case EventOAApprovalTaskRedirected: + return OAApprovalTaskRedirectedOutput{ + Type: base.Type, + EventID: base.EventID, + Timestamp: base.Timestamp, + SubscribeID: base.SubscribeID, + ProcessInstanceID: payload.Body.ProcessInstanceID, + ProcessCode: payload.Body.ProcessCode, + TaskID: payload.Body.TaskID, + Title: payload.Body.Title, + Status: payload.Body.Status, + Result: payload.Body.Result, + CreateTime: payload.Body.CreateTime, + FinishTime: payload.Body.FinishTime, + EventTime: payload.EventTime, + }, nil + case EventOAApprovalInstanceStarted: + return OAApprovalInstanceStartedOutput{ + Type: base.Type, + EventID: base.EventID, + Timestamp: base.Timestamp, + SubscribeID: base.SubscribeID, + ProcessInstanceID: payload.Body.ProcessInstanceID, + ProcessCode: payload.Body.ProcessCode, + Title: payload.Body.Title, + Status: payload.Body.Status, + CreateTime: payload.Body.CreateTime, + EventTime: payload.EventTime, + }, nil + case EventOAApprovalInstanceTerminated: + return OAApprovalInstanceTerminatedOutput{ + Type: base.Type, + EventID: base.EventID, + Timestamp: base.Timestamp, + SubscribeID: base.SubscribeID, + ProcessInstanceID: payload.Body.ProcessInstanceID, + ProcessCode: payload.Body.ProcessCode, + Title: payload.Body.Title, + Status: payload.Body.Status, + CreateTime: payload.Body.CreateTime, + FinishTime: payload.Body.FinishTime, + EventTime: payload.EventTime, + }, nil + case EventOAApprovalInstanceFinished: + return OAApprovalInstanceFinishedOutput{ + Type: base.Type, + EventID: base.EventID, + Timestamp: base.Timestamp, + SubscribeID: base.SubscribeID, + ProcessInstanceID: payload.Body.ProcessInstanceID, + ProcessCode: payload.Body.ProcessCode, + Title: payload.Body.Title, + Status: payload.Body.Status, + Result: payload.Body.Result, + CreateTime: payload.Body.CreateTime, + FinishTime: payload.Body.FinishTime, + EventTime: payload.EventTime, + }, nil + default: + return ev, fmt.Errorf("unsupported personal OA event type %q", base.Type) + } +} + func decodeRequiredPayload(raw json.RawMessage, target any) error { trimmed := bytes.TrimSpace(raw) if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { @@ -674,8 +863,18 @@ func outputTypeForEvent(eventKey string) reflect.Type { return reflect.TypeOf(GroupMemberEventOutput{}) case isGroupLifecycleEvent(eventKey): return reflect.TypeOf(GroupLifecycleEventOutput{}) - case isOAEvent(eventKey): - return reflect.TypeOf(OAEventOutput{}) + case eventKey == EventOAApprovalTaskCreated: + return reflect.TypeOf(OAApprovalTaskCreatedOutput{}) + case eventKey == EventOAApprovalTaskFinished: + return reflect.TypeOf(OAApprovalTaskFinishedOutput{}) + case eventKey == EventOAApprovalTaskRedirected: + return reflect.TypeOf(OAApprovalTaskRedirectedOutput{}) + case eventKey == EventOAApprovalInstanceStarted: + return reflect.TypeOf(OAApprovalInstanceStartedOutput{}) + case eventKey == EventOAApprovalInstanceTerminated: + return reflect.TypeOf(OAApprovalInstanceTerminatedOutput{}) + case eventKey == EventOAApprovalInstanceFinished: + return reflect.TypeOf(OAApprovalInstanceFinishedOutput{}) default: return reflect.TypeOf(baseEventOutput{}) } @@ -711,6 +910,12 @@ func isOAEvent(eventKey string) bool { eventKey == EventOAApprovalInstanceFinished } +func isOAApprovalTaskEvent(eventKey string) bool { + return eventKey == EventOAApprovalTaskCreated || + eventKey == EventOAApprovalTaskFinished || + eventKey == EventOAApprovalTaskRedirected +} + func schemaType(t reflect.Type) string { switch t.Kind() { case reflect.String: diff --git a/internal/event/personal/output_test.go b/internal/event/personal/output_test.go index 0bba024c..10884c7e 100644 --- a/internal/event/personal/output_test.go +++ b/internal/event/personal/output_test.go @@ -151,25 +151,56 @@ func personalGroupMemberData(eventKey string) string { } func personalOAData(eventKey string) string { - return fmt.Sprintf(`{ - "eventId":"oa-event", - "eventKey":%q, - "occurredAtMs":1785229200123, - "subId":"oa-data-sub", - "payload":{ - "uid":100001, - "CORPID":"internal-corp", - "clientId":"internal-client", - "filterSubId":"internal-filter", - "bizid":"internal-biz", - "orgId":100002, - "sourceId":"open", - "processInstanceId":"process-instance-1", - "taskId":"approval-task-1", - "futureField":{"nested":true,"uid":"business-user-1"}, - "steps":[{"name":"审批","result":"agree"}] - } - }`, eventKey) + body := map[string]any{ + "processInstanceId": "process-instance-1", + "createTime": int64(1785229100000), + "processCode": "PROC-TEST-1", + "title": "测试审批", + } + switch eventKey { + case EventOAApprovalTaskCreated: + body["taskId"] = "approval-task-1" + body["status"] = "RUNNING" + case EventOAApprovalTaskFinished: + body["taskId"] = "approval-task-1" + body["status"] = "FINISHED" + body["result"] = "agree" + body["finishTime"] = int64(1785229199000) + case EventOAApprovalTaskRedirected: + body["taskId"] = "approval-task-1" + body["status"] = "FINISHED" + body["result"] = "redirect" + body["finishTime"] = int64(1785229199000) + case EventOAApprovalInstanceStarted: + body["status"] = "RUNNING" + case EventOAApprovalInstanceTerminated: + body["status"] = "TERMINATED" + body["finishTime"] = int64(1785229199000) + case EventOAApprovalInstanceFinished: + body["status"] = "FINISHED" + body["result"] = "agree" + body["finishTime"] = int64(1785229199000) + } + data := map[string]any{ + "eventId": "oa-event", + "eventKey": eventKey, + "occurredAtMs": int64(1785229200123), + "subId": "oa-data-sub", + "payload": map[string]any{ + "uid": 100001, + "CORPID": "internal-corp", + "clientId": "internal-client", + "filterSubId": "internal-filter", + "bizid": "internal-biz", + "orgId": 100002, + "sourceId": "open", + "body": body, + "event_time": int64(1785229199000), + "futureField": map[string]any{"nested": true}, + }, + } + encoded, _ := json.Marshal(data) + return string(encoded) } func TestCrossPlatformCoverageProjectOutputMessageEvents(t *testing.T) { @@ -419,48 +450,127 @@ func TestCrossPlatformCoverageProjectOutputGroupLifecycleEvents(t *testing.T) { } func TestCrossPlatformCoverageProjectOutputOAEvents(t *testing.T) { - for _, eventKey := range []string{ - EventOAApprovalTaskCreated, - EventOAApprovalTaskFinished, - EventOAApprovalTaskRedirected, - EventOAApprovalInstanceStarted, - EventOAApprovalInstanceTerminated, - EventOAApprovalInstanceFinished, - } { - t.Run(eventKey, func(t *testing.T) { + tests := []struct { + eventKey string + want any + }{ + { + eventKey: EventOAApprovalTaskCreated, + want: OAApprovalTaskCreatedOutput{ + Type: EventOAApprovalTaskCreated, + EventID: "oa-event", + Timestamp: 1785229200123, + SubscribeID: "outer-sub", + ProcessInstanceID: "process-instance-1", + ProcessCode: "PROC-TEST-1", + TaskID: "approval-task-1", + Title: "测试审批", + Status: "RUNNING", + CreateTime: 1785229100000, + EventTime: 1785229199000, + }, + }, + { + eventKey: EventOAApprovalTaskFinished, + want: OAApprovalTaskFinishedOutput{ + Type: EventOAApprovalTaskFinished, + EventID: "oa-event", + Timestamp: 1785229200123, + SubscribeID: "outer-sub", + ProcessInstanceID: "process-instance-1", + ProcessCode: "PROC-TEST-1", + TaskID: "approval-task-1", + Title: "测试审批", + Status: "FINISHED", + Result: "agree", + CreateTime: 1785229100000, + FinishTime: 1785229199000, + EventTime: 1785229199000, + }, + }, + { + eventKey: EventOAApprovalTaskRedirected, + want: OAApprovalTaskRedirectedOutput{ + Type: EventOAApprovalTaskRedirected, + EventID: "oa-event", + Timestamp: 1785229200123, + SubscribeID: "outer-sub", + ProcessInstanceID: "process-instance-1", + ProcessCode: "PROC-TEST-1", + TaskID: "approval-task-1", + Title: "测试审批", + Status: "FINISHED", + Result: "redirect", + CreateTime: 1785229100000, + FinishTime: 1785229199000, + EventTime: 1785229199000, + }, + }, + { + eventKey: EventOAApprovalInstanceStarted, + want: OAApprovalInstanceStartedOutput{ + Type: EventOAApprovalInstanceStarted, + EventID: "oa-event", + Timestamp: 1785229200123, + SubscribeID: "outer-sub", + ProcessInstanceID: "process-instance-1", + ProcessCode: "PROC-TEST-1", + Title: "测试审批", + Status: "RUNNING", + CreateTime: 1785229100000, + EventTime: 1785229199000, + }, + }, + { + eventKey: EventOAApprovalInstanceTerminated, + want: OAApprovalInstanceTerminatedOutput{ + Type: EventOAApprovalInstanceTerminated, + EventID: "oa-event", + Timestamp: 1785229200123, + SubscribeID: "outer-sub", + ProcessInstanceID: "process-instance-1", + ProcessCode: "PROC-TEST-1", + Title: "测试审批", + Status: "TERMINATED", + CreateTime: 1785229100000, + FinishTime: 1785229199000, + EventTime: 1785229199000, + }, + }, + { + eventKey: EventOAApprovalInstanceFinished, + want: OAApprovalInstanceFinishedOutput{ + Type: EventOAApprovalInstanceFinished, + EventID: "oa-event", + Timestamp: 1785229200123, + SubscribeID: "outer-sub", + ProcessInstanceID: "process-instance-1", + ProcessCode: "PROC-TEST-1", + Title: "测试审批", + Status: "FINISHED", + Result: "agree", + CreateTime: 1785229100000, + FinishTime: 1785229199000, + EventTime: 1785229199000, + }, + }, + } + for _, tt := range tests { + t.Run(tt.eventKey, func(t *testing.T) { projected, err := ProjectOutput(transport.Event{ EventID: "outer-event", EventBornTime: 11, - EventType: eventKey, + EventType: tt.eventKey, SubscribeID: "outer-sub", - Data: personalOAData(eventKey), + Data: personalOAData(tt.eventKey), }) if err != nil { t.Fatalf("ProjectOutput() error = %v", err) } - got, ok := projected.(OAEventOutput) - if !ok { - t.Fatalf("ProjectOutput() type = %T, want OAEventOutput", projected) - } - if got.Type != eventKey || got.EventID != "oa-event" || got.Timestamp != 1785229200123 || got.SubscribeID != "outer-sub" { - t.Fatalf("common fields = %#v", got) - } - for _, internal := range []string{"uid", "CORPID", "clientId", "filterSubId", "bizid", "orgId", "sourceId"} { - if _, ok := got.Payload[internal]; ok { - t.Fatalf("payload retained internal field %q: %#v", internal, got.Payload) - } - } - if got.Payload["processInstanceId"] != "process-instance-1" || got.Payload["taskId"] != "approval-task-1" { - t.Fatalf("payload lost known business fields: %#v", got.Payload) - } - future, ok := got.Payload["futureField"].(map[string]any) - if !ok || future["nested"] != true || future["uid"] != "business-user-1" { - t.Fatalf("payload lost unknown or nested business fields: %#v", got.Payload["futureField"]) - } - steps, ok := got.Payload["steps"].([]any) - if !ok || len(steps) != 1 { - t.Fatalf("payload lost unknown array field: %#v", got.Payload["steps"]) + if !reflect.DeepEqual(projected, tt.want) { + t.Fatalf("ProjectOutput() = %#v, want %#v", projected, tt.want) } + assertNoInternalActionFields(t, projected) }) } } @@ -479,9 +589,9 @@ func TestCrossPlatformCoverageProjectOutputOADecodesDoublyWrappedJSONString(t *t if err != nil { t.Fatalf("ProjectOutput() error = %v", err) } - got, ok := projected.(OAEventOutput) + got, ok := projected.(OAApprovalTaskCreatedOutput) if !ok { - t.Fatalf("ProjectOutput() type = %T, want OAEventOutput", projected) + t.Fatalf("ProjectOutput() type = %T, want OAApprovalTaskCreatedOutput", projected) } if got.Type != EventOAApprovalTaskCreated || got.EventID != "oa-event" || got.SubscribeID != "oa-data-sub" { t.Fatalf("ProjectOutput() = %#v", got) @@ -651,6 +761,9 @@ func TestCrossPlatformCoverageProjectOutputRejectsInvalidOAPayloads(t *testing.T {name: "empty object", payload: `,"payload":{}`}, {name: "array", payload: `,"payload":[]`}, {name: "string", payload: `,"payload":"invalid"`}, + {name: "missing body", payload: `,"payload":{"event_time":1}`}, + {name: "null body", payload: `,"payload":{"body":null,"event_time":1}`}, + {name: "empty body", payload: `,"payload":{"body":{},"event_time":1}`}, } for _, eventKey := range []string{ EventOAApprovalTaskCreated, @@ -683,6 +796,48 @@ func TestCrossPlatformCoverageProjectOutputRejectsInvalidOAPayloads(t *testing.T } } +func TestCrossPlatformCoverageProjectOutputRejectsOAWithoutStableIDs(t *testing.T) { + tests := []struct { + name string + eventKey string + body string + want string + }{ + { + name: "missing process instance", + eventKey: EventOAApprovalInstanceStarted, + body: `{"status":"RUNNING"}`, + want: "processInstanceId is required", + }, + { + name: "missing task", + eventKey: EventOAApprovalTaskCreated, + body: `{"processInstanceId":"process-instance-1","status":"RUNNING"}`, + want: "taskId is required", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ev := transport.Event{ + EventID: "outer-event", + EventType: tt.eventKey, + Data: fmt.Sprintf( + `{"eventKey":%q,"payload":{"body":%s,"event_time":1}}`, + tt.eventKey, + tt.body, + ), + } + projected, err := ProjectOutput(ev) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("ProjectOutput() error = %v, want %q", err, tt.want) + } + if got, ok := projected.(transport.Event); !ok || !reflect.DeepEqual(got, ev) { + t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev) + } + }) + } +} + func TestCrossPlatformCoverageProjectOutputDecodesWrappedJSONString(t *testing.T) { wrapped, err := json.Marshal(personalMessageData(EventSingleChat)) if err != nil { diff --git a/internal/event/personal/registry_test.go b/internal/event/personal/registry_test.go index fa39e135..fb01a3b3 100644 --- a/internal/event/personal/registry_test.go +++ b/internal/event/personal/registry_test.go @@ -474,41 +474,91 @@ func TestGroupLifecycleSchemaDocumentsUseConservativePayload(t *testing.T) { } } -func TestOAEventSchemaDocumentsUseConservativePayload(t *testing.T) { - wantProperties := []string{"type", "event_id", "timestamp", "subscribe_id", "payload"} - for _, eventKey := range []string{ - EventOAApprovalTaskCreated, - EventOAApprovalTaskFinished, - EventOAApprovalTaskRedirected, - EventOAApprovalInstanceStarted, - EventOAApprovalInstanceTerminated, - EventOAApprovalInstanceFinished, - } { - t.Run(eventKey, func(t *testing.T) { - def, ok := Lookup(eventKey) +func TestOAEventSchemaDocumentsMatchOutputDTO(t *testing.T) { + tests := []struct { + eventKey string + properties []string + }{ + { + eventKey: EventOAApprovalTaskCreated, + properties: []string{ + "type", "event_id", "timestamp", "subscribe_id", "process_instance_id", + "process_code", "task_id", "title", "status", "create_time", "event_time", + }, + }, + { + eventKey: EventOAApprovalTaskFinished, + properties: []string{ + "type", "event_id", "timestamp", "subscribe_id", "process_instance_id", + "process_code", "task_id", "title", "status", "result", "create_time", + "finish_time", "event_time", + }, + }, + { + eventKey: EventOAApprovalTaskRedirected, + properties: []string{ + "type", "event_id", "timestamp", "subscribe_id", "process_instance_id", + "process_code", "task_id", "title", "status", "result", "create_time", + "finish_time", "event_time", + }, + }, + { + eventKey: EventOAApprovalInstanceStarted, + properties: []string{ + "type", "event_id", "timestamp", "subscribe_id", "process_instance_id", + "process_code", "title", "status", "create_time", "event_time", + }, + }, + { + eventKey: EventOAApprovalInstanceTerminated, + properties: []string{ + "type", "event_id", "timestamp", "subscribe_id", "process_instance_id", + "process_code", "title", "status", "create_time", "finish_time", "event_time", + }, + }, + { + eventKey: EventOAApprovalInstanceFinished, + properties: []string{ + "type", "event_id", "timestamp", "subscribe_id", "process_instance_id", + "process_code", "title", "status", "result", "create_time", "finish_time", + "event_time", + }, + }, + } + for _, tt := range tests { + t.Run(tt.eventKey, func(t *testing.T) { + def, ok := Lookup(tt.eventKey) if !ok { - t.Fatalf("Lookup(%q) failed", eventKey) + t.Fatalf("Lookup(%q) failed", tt.eventKey) } doc := BuildSchemaDocumentForMode(def, true) if doc.JQRootPath != "." { t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath) } props, ok := doc.Schema["properties"].(map[string]any) - if !ok || len(props) != len(wantProperties) { - t.Fatalf("schema.properties = %#v, want exactly %d fields", doc.Schema["properties"], len(wantProperties)) + if !ok || len(props) != len(tt.properties) { + t.Fatalf("schema.properties = %#v, want exactly %d fields", doc.Schema["properties"], len(tt.properties)) } - for _, name := range wantProperties { + for _, name := range tt.properties { if _, ok := props[name].(map[string]any); !ok { t.Fatalf("schema.properties.%s = %#v, want object", name, props[name]) } } eventType := props["type"].(map[string]any) - if !reflect.DeepEqual(eventType["enum"], []string{eventKey}) { - t.Fatalf("schema.properties.type.enum = %#v, want %q", eventType["enum"], eventKey) + if !reflect.DeepEqual(eventType["enum"], []string{tt.eventKey}) { + t.Fatalf("schema.properties.type.enum = %#v, want %q", eventType["enum"], tt.eventKey) } - payload := props["payload"].(map[string]any) - if payload["type"] != "object" || payload["additionalProperties"] != true { - t.Fatalf("schema.properties.payload = %#v, want open object", payload) + if _, ok := props["payload"]; ok { + t.Fatalf("schema.properties exposed generic payload: %#v", props) + } + for _, name := range []string{"timestamp", "create_time", "finish_time", "event_time"} { + property, exists := props[name].(map[string]any) + if !exists { + continue + } + if property["type"] != "integer" || property["format"] != "timestamp_ms" { + t.Fatalf("schema.properties.%s = %#v, want timestamp_ms integer", name, property) + } } }) } diff --git a/skills/mono/references/products/event.md b/skills/mono/references/products/event.md index be5758d7..9ce222ba 100644 --- a/skills/mono/references/products/event.md +++ b/skills/mono/references/products/event.md @@ -96,7 +96,7 @@ 1. 从用户意图选择事件码;人名或群名先解析成必填 ID。 2. 需要了解字段时运行 `dws event schema --flatten`,读取 `schema.properties`;此模式的 `jq_root_path` 为 `.`。 3. 启动 `dws event consume [event_key...] ... --flatten -f ndjson`。单事件等待 `ready event_key=...`;多事件记录每条 `subscription event_key=... subscribe_id=...`,再等待整体 `ready event_count=...`。不要用 `sleep` 猜测。 -4. stdout 每行是一个扁平事件 JSON;消息、动作及群成员加入/退出事件读取顶层业务字段。群标题变更、群解散和 OA 事件只读取公共字段和 `payload` 中实际存在的字段。 +4. stdout 每行是一个扁平事件 JSON;消息、动作、群成员加入/退出及 OA 审批事件读取顶层业务字段。群标题变更和群解散只读取公共字段和 `payload` 中实际存在的字段。 5. 需要确认监听状态时运行 `dws event status --event `,查看 `Subscriptions` 和 `Consumers`。 6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先运行 `dws event stop --dry-run`,向用户确认后再以 `--yes` 执行;自测可在 consume 加 `--max-events` 或 `--duration` 自动退出。 @@ -219,7 +219,7 @@ dws event stop --all --yes - 表情回应事件直接读取 `operator/operator_open_dingtalk_id/reaction_name/reaction_text/operation_type/operation_time`。 - 群成员加入/退出事件读取 `conversation_id/operator/operator_open_dingtalk_id/members/event_time`。`operator` 是执行操作的人,`members` 是本次加入或退出的成员数组,成员项包含 `nick/open_dingtalk_id`;系统操作或成员自行退出时操作人字段可能为空。 - 群标题变更和群解散当前只承诺 `type/event_id/timestamp/subscribe_id/payload`;以实际 `payload` 为准,不猜测群标题、操作者等字段。 -- OA 事件同样只承诺 `type/event_id/timestamp/subscribe_id/payload`。`payload` 是允许任意业务字段的开放对象,内部路由字段会被移除;不要猜测 `taskId`、`processInstanceId` 或审批完成状态枚举。payload 缺失或非法时 stderr 会输出 warning,stdout 回退为原始 transport envelope。 +- OA 事件读取顶层 `process_instance_id/process_code/title/status/create_time/event_time`;任务事件另有 `task_id`,完成、转交或终止事件按对应 schema 提供 `finish_time`,任务完成、任务转交和实例完成还提供 `result`。`status/result` 保留服务端实际值,不推断完整枚举;缺少稳定 ID 或 payload 非法时 stderr 会输出 warning,stdout 回退为原始 transport envelope。 - 图片、文件等媒体消息的 `content` 可能是可读描述;合并转发媒体的下载定位信息位于对应 `forward_messages[].content`。需要实际媒体文件时调用 `dws chat message download-media`。 - 正常动作事件输出不含内部 `payload/uid/corpid/clientId/filterSubId/bizid`;原始排查才使用 `-f raw` 或 `--debug-raw-events`。 - 自己发的消息不作为事件回来(`isSelfLoop` 过滤);自发验证会看到 0 事件,测试投递使用别人或机器人发消息。 diff --git a/skills/multi/dingtalk-event/SKILL.md b/skills/multi/dingtalk-event/SKILL.md index a272b673..b9b76a6d 100644 --- a/skills/multi/dingtalk-event/SKILL.md +++ b/skills/multi/dingtalk-event/SKILL.md @@ -87,7 +87,7 @@ description: 钉钉个人 IM 事件与 OA 审批事件长连接监听、订阅 1. 从用户意图选择事件码;人名或群名先解析成必填 ID。 2. 需要了解字段时运行 `dws event schema --flatten`,读取 `schema.properties`;此模式的 `jq_root_path` 为 `.`。 3. 启动 `dws event consume [event_key...] ... --flatten -f ndjson`。单事件等待 `[event] ready event_key= bus_pid= subscribe_id=`;多事件先记录每条 `[event] subscription event_key= subscribe_id=`,再等待 `[event] ready event_count= bus_pid=`。不要用 `sleep` 猜测。 -4. stdout 每行是一个扁平事件 JSON;消息、动作及群成员加入/退出事件直接读取顶层业务字段。群标题变更、群解散和 OA 事件只读取公共字段与 `payload` 中实际存在的字段。 +4. stdout 每行是一个扁平事件 JSON;消息、动作、群成员加入/退出及 OA 审批事件直接读取顶层业务字段。群标题变更和群解散只读取公共字段与 `payload` 中实际存在的字段。 5. 需要确认监听状态时运行 `dws event status --event `,查看 `Subscriptions` 和 `Consumers`。 6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先用 `dws event stop --dry-run` 预览,向用户确认后再加 `--yes`;临时测试可用 `--max-events` 或 `--duration` 自动退出。 @@ -241,7 +241,7 @@ dws event consume user_im_message_receive_o2o \ - 引用回复读取可选的 `quoted_message`;合并转发读取可选的 `forward_messages` 数组。两者保留内部消息的 `message_id/conversation_id/sender/sender_open_dingtalk_id/content/create_time`;不要通过“聊天记录”等本地化外层文案识别或拆分合并转发。 - 群成员加入/退出事件读取顶层 `conversation_id`、`operator`、`operator_open_dingtalk_id`、`members`、`event_time`。`operator` 是执行操作的人,`members` 是本次加入或退出的成员数组;成员项读取 `nick` 和 `open_dingtalk_id`。系统操作或成员自行退出时,操作人字段可能为空。 - 群标题变更和群解散当前只承诺顶层 `type/event_id/timestamp/subscribe_id/payload`。读取 `payload` 时以实际键为准,不猜测群标题、操作者等尚未确认的字段;完整原始协议用 `-f raw` 或 `--debug-raw-events` 排查。 -- OA 事件当前同样只承诺顶层 `type/event_id/timestamp/subscribe_id/payload`;`payload` 保留服务端实际业务字段并移除内部路由字段。不要猜测 `taskId`、`processInstanceId` 或完成状态枚举;payload 缺失或非法时会告警并回退输出原始 transport envelope。 +- OA 事件读取顶层 `process_instance_id/process_code/title/status/create_time/event_time`。任务事件另有 `task_id`;完成、转交或终止事件按对应 schema 提供 `finish_time`,任务完成、任务转交和实例完成还提供 `result`。`status/result` 的值以服务端实际推送为准,不自行补充枚举;缺少稳定 ID 或 payload 非法时会告警并回退输出原始 transport envelope。 - 群自动回复使用事件顶层 `conversation_id`;单聊自动回复使用顶层 `sender_open_dingtalk_id`。 - 已读事件读取顶层 `reader`、`reader_open_dingtalk_id`、`read_time`;撤回事件读取 `recaller`、`recaller_open_dingtalk_id`、`recall_time`。 - 表情回应事件读取顶层 `operator`、`operator_open_dingtalk_id`、`reaction_name`、`reaction_text`、`operation_type`、`operation_time`。 @@ -252,4 +252,4 @@ dws event consume user_im_message_receive_o2o \ | Topic | Reference | Coverage | |---|---|---| | IM | [references/event-im.md](references/event-im.md) | 十六类个人 IM 事件命令、参数、生命周期、输出解析、自测和排障 | -| OA | [references/event-oa.md](references/event-oa.md) | 六类个人 OA 审批事件、无参数订阅、多事件消费和保守 payload 契约 | +| OA | [references/event-oa.md](references/event-oa.md) | 六类个人 OA 审批事件、无参数订阅、多事件消费和稳定扁平输出字段 | diff --git a/skills/multi/dingtalk-event/references/event-oa.md b/skills/multi/dingtalk-event/references/event-oa.md index 81039ee0..f5841302 100644 --- a/skills/multi/dingtalk-event/references/event-oa.md +++ b/skills/multi/dingtalk-event/references/event-oa.md @@ -41,7 +41,7 @@ dws auth login | “查看 OA 事件目录” | `dws event list --category oa` | | “查看 OA 事件输出字段” | 对对应事件运行 `dws event schema --flatten` | -三个审批任务事件分别表达任务已创建、已完成和已转交;三个审批实例事件分别表达实例已发起、已终止和已完成。首版不推断审批结果、任务 ID、实例 ID、转交对象或终止原因的具体 payload 字段。 +三个审批任务事件分别表达任务已创建、已完成和已转交;三个审批实例事件分别表达实例已发起、已终止和已完成。扁平字段来自六类事件的预发联调样本;`status` 和 `result` 保留服务端原值,不把当前样本值推断为完整枚举。 ## Commands @@ -85,7 +85,7 @@ dws event consume \ ## Output contract -`--flatten` 模式只承诺以下稳定结构: +`--flatten` 模式的所有 OA 事件都包含以下顶层字段: ```json { @@ -93,14 +93,52 @@ dws event consume \ "event_id": "...", "timestamp": 0, "subscribe_id": "...", - "payload": {} + "process_instance_id": "...", + "process_code": "...", + "title": "...", + "status": "RUNNING", + "create_time": 0, + "event_time": 0 } ``` -- `type` 是当前 event key;`event_id` 可用于去重;`timestamp` 是事件发生时间戳;`subscribe_id` 标识对应的独立订阅。 -- `payload` 是开放业务对象,schema 使用 `additionalProperties=true`。未知业务字段原样保留,内部路由字段 `uid/corpid/clientId/filterSubId/bizid/orgId/sourceId` 会被移除。 -- 不要假设 `payload` 一定包含 `taskId`、`processInstanceId`、`processCode`、审批结果或完成状态,也不要猜测状态枚举。始终以实际 payload 和 `dws event schema --flatten` 为准。 -- payload 缺失、为空或无法解析时,consume 会在 stderr 记录 warning,并把原始 transport envelope 写到 stdout,保证事件不被静默丢弃。 +- `type` 是当前 event key;`event_id` 可用于去重;`timestamp` 是 transport 事件发生时间;`subscribe_id` 标识对应的独立订阅。 +- `process_instance_id` 是审批实例 ID,可传给 OA 审批命令的 `--instance-id`;`process_code` 是审批流程模板编码。 +- `create_time`、`finish_time` 和 `event_time` 都是毫秒时间戳。`event_time` 是审批业务事件时间,`timestamp` 是 transport 事件时间。 +- 六类事件的额外字段如下;具体事件始终以 `dws event schema --flatten` 为准。 + +| 事件 | 额外顶层字段 | +|---|---| +| `user_oa_approval_task_created` | `task_id` | +| `user_oa_approval_task_finished` | `task_id`、`result`、`finish_time` | +| `user_oa_approval_task_redirected` | `task_id`、`result`、`finish_time` | +| `user_oa_approval_instance_started` | 无 | +| `user_oa_approval_instance_terminated` | `finish_time` | +| `user_oa_approval_instance_finished` | `result`、`finish_time` | + +任务完成事件示例: + +```json +{ + "type": "user_oa_approval_task_finished", + "event_id": "...", + "timestamp": 0, + "subscribe_id": "...", + "process_instance_id": "...", + "process_code": "...", + "task_id": "...", + "title": "测试审批", + "status": "FINISHED", + "result": "agree", + "create_time": 0, + "finish_time": 0, + "event_time": 0 +} +``` + +- `task_id` 是当前审批任务 ID,可传给接受任务 ID 的 OA 审批命令。 +- `status` 和 `result` 是服务端字符串;不要只根据当前样本把 `RUNNING/FINISHED/TERMINATED` 或 `agree/redirect` 写成封闭枚举。 +- payload 缺失、为空、缺少对应事件的稳定 ID 或无法解析时,consume 会在 stderr 记录 warning,并把原始 transport envelope 写到 stdout,保证事件不被静默丢弃。 - 不传 `--flatten` 时保持兼容 transport envelope,业务 payload 位于 `.data | fromjson`。需要联调完整原始协议时使用不带 `--flatten` 的 `-f raw` 或 `--debug-raw-events`。 ## Lifecycle