feat(event): publish typed OA approval schemas

This commit is contained in:
炳昱
2026-07-29 22:23:13 +08:00
parent 753d538140
commit 703406df13
10 changed files with 622 additions and 126 deletions
+61 -13
View File
@@ -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) {
@@ -1,6 +1,6 @@
{
"version": 1,
"source_hash": "sha256:853478184de2998932c3ce94cab108a1640a96b17a9db2faedcefb169e80e690",
"source_hash": "sha256:c2d5854a1d9b0f6570e2f60283529d84b3f7a1d231cb5f76a0007c479d40cefe",
"surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed",
"coverage": {
"surface_products": 26,
@@ -1,6 +1,6 @@
{
"version": 1,
"source_hash": "sha256:853478184de2998932c3ce94cab108a1640a96b17a9db2faedcefb169e80e690",
"source_hash": "sha256:c2d5854a1d9b0f6570e2f60283529d84b3f7a1d231cb5f76a0007c479d40cefe",
"surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed",
"source_files": 160,
"hint_files": 54,
+2 -2
View File
@@ -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,
+227 -22
View File
@@ -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:
+209 -54
View File
@@ -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 {
+71 -21
View File
@@ -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)
}
}
})
}
+2 -2
View File
@@ -96,7 +96,7 @@
1. 从用户意图选择事件码;人名或群名先解析成必填 ID。
2. 需要了解字段时运行 `dws event schema <event_key> --flatten`,读取 `schema.properties`;此模式的 `jq_root_path` 为 `.`。
3. 启动 `dws event consume <event_key> [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 <event_key>`,查看 `Subscriptions` 和 `Consumers`。
6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先运行 `dws event stop <subscribe_id> --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 事件,测试投递使用别人或机器人发消息。
+3 -3
View File
@@ -87,7 +87,7 @@ description: 钉钉个人 IM 事件与 OA 审批事件长连接监听、订阅
1. 从用户意图选择事件码;人名或群名先解析成必填 ID。
2. 需要了解字段时运行 `dws event schema <event_key> --flatten`,读取 `schema.properties`;此模式的 `jq_root_path` 为 `.`。
3. 启动 `dws event consume <event_key> [event_key...] ... --flatten -f ndjson`。单事件等待 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>`;多事件先记录每条 `[event] subscription event_key=<key> subscribe_id=<id>`,再等待 `[event] ready event_count=<n> bus_pid=<pid>`。不要用 `sleep` 猜测。
4. stdout 每行是一个扁平事件 JSON;消息、动作及群成员加入/退出事件直接读取顶层业务字段。群标题变更、群解散和 OA 事件只读取公共字段与 `payload` 中实际存在的字段。
4. stdout 每行是一个扁平事件 JSON;消息、动作、群成员加入/退出及 OA 审批事件直接读取顶层业务字段。群标题变更和群解散只读取公共字段与 `payload` 中实际存在的字段。
5. 需要确认监听状态时运行 `dws event status --event <event_key>`,查看 `Subscriptions` 和 `Consumers`。
6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先用 `dws event stop <subscribe_id> --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 审批事件、无参数订阅、多事件消费和稳定扁平输出字段 |
@@ -41,7 +41,7 @@ dws auth login
| “查看 OA 事件目录” | `dws event list --category oa` |
| “查看 OA 事件输出字段” | 对对应事件运行 `dws event schema <event_key> --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 <event_key> --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 <event_key> --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