1pub use crate::approval::approval::v4::instance::add_cc::{
7 AddCcInstanceBodyV4, AddCcInstanceRequestV4, AddCcInstanceResponseV4,
8};
9pub use crate::approval::approval::v4::instance::detail::{
10 DetailInstanceRequestV4, DetailInstanceResponseV4, DetailInstanceTaskV4,
11};
12pub use crate::approval::approval::v4::instance::initiated::{
13 InitiatedInstanceItemV4, InitiatedInstanceRequestV4, InitiatedInstanceResponseV4,
14 InstanceSummaryV4,
15};
16pub use crate::approval::approval::v4::instance::recall::{
17 RecallInstanceBodyV4, RecallInstanceRequestV4, RecallInstanceResponseV4,
18};
19pub use crate::approval::approval::v4::instance::remind::{
20 RemindInstanceBodyV4, RemindInstanceRequestV4, RemindInstanceResponseV4,
21};
22pub use crate::approval::approval::v4::task::add_sign::{
23 AddSignTaskBodyV4, AddSignTaskRequestV4, AddSignTaskResponseV4,
24};
25pub use crate::approval::approval::v4::task::forward::{
26 ForwardTaskBodyV4, ForwardTaskRequestV4, ForwardTaskResponseV4,
27};
28pub use crate::approval::approval::v4::task::list::{
29 ListTaskItemV4, ListTaskRequestV4, ListTaskResponseV4, TaskSummaryV4,
30};
31pub use crate::approval::approval::v4::task::pass::{
32 PassTaskBodyV4, PassTaskRequestV4, PassTaskResponseV4,
33};
34pub use crate::approval::approval::v4::task::refuse::{
35 RefuseTaskBodyV4, RefuseTaskRequestV4, RefuseTaskResponseV4,
36};
37pub use crate::approval::approval::v4::task::rollback::{
38 RollbackTaskBodyV4, RollbackTaskRequestV4, RollbackTaskResponseV4,
39};
40
41use openlark_core::{SDKResult, config::Config};
42use std::sync::Arc;
43
44use crate::common::constants::MAX_PAGE_SIZE;
45
46#[derive(Debug, Clone, Default, PartialEq)]
50pub struct WorkflowTaskListQuery {
51 pub tasklist_guid: Option<String>,
53 pub section_guid: Option<String>,
55 pub filter: Option<String>,
57 pub sort: Option<serde_json::Value>,
59 pub user_type: Option<String>,
61 pub page_size: Option<i32>,
63}
64
65impl WorkflowTaskListQuery {
66 pub fn for_tasklist(tasklist_guid: impl Into<String>) -> Self {
68 Self {
69 tasklist_guid: Some(tasklist_guid.into()),
70 ..Self::default()
71 }
72 }
73
74 pub fn section_guid(mut self, section_guid: impl Into<String>) -> Self {
76 self.section_guid = Some(section_guid.into());
77 self
78 }
79
80 pub fn filter(mut self, filter: impl Into<String>) -> Self {
82 self.filter = Some(filter.into());
83 self
84 }
85
86 pub fn sort(mut self, sort: serde_json::Value) -> Self {
88 self.sort = Some(sort);
89 self
90 }
91
92 pub fn user_type(mut self, user_type: impl Into<String>) -> Self {
94 self.user_type = Some(user_type.into());
95 self
96 }
97
98 pub fn page_size(mut self, page_size: i32) -> Self {
100 self.page_size = Some(page_size);
101 self
102 }
103}
104
105#[derive(Debug, Clone, Default, PartialEq)]
109pub struct WorkflowTaskMutation {
110 pub summary: Option<String>,
112 pub description: Option<String>,
114 pub due: Option<String>,
116 pub priority: Option<i32>,
118 pub assignee: Option<String>,
120 pub status: Option<String>,
122}
123
124impl WorkflowTaskMutation {
125 pub fn new() -> Self {
127 Self::default()
128 }
129
130 pub fn summary(mut self, summary: impl Into<String>) -> Self {
132 self.summary = Some(summary.into());
133 self
134 }
135
136 pub fn description(mut self, description: impl Into<String>) -> Self {
138 self.description = Some(description.into());
139 self
140 }
141
142 pub fn due(mut self, due: impl Into<String>) -> Self {
144 self.due = Some(due.into());
145 self
146 }
147
148 pub fn priority(mut self, priority: i32) -> Self {
150 self.priority = Some(priority);
151 self
152 }
153
154 pub fn assignee(mut self, assignee: impl Into<String>) -> Self {
156 self.assignee = Some(assignee.into());
157 self
158 }
159
160 pub fn status(mut self, status: impl Into<String>) -> Self {
163 self.status = Some(status.into());
164 self
165 }
166}
167
168#[derive(Debug, Clone, Default, PartialEq)]
172pub struct ApprovalTaskQuery {
173 pub user_id: String,
175 pub topic: String,
177 pub user_id_type: Option<String>,
179 pub status: Option<String>,
181 pub instance_code: Option<String>,
183 pub page_size: Option<i32>,
185}
186
187impl ApprovalTaskQuery {
188 pub fn new(user_id: impl Into<String>, topic: impl Into<String>) -> Self {
190 Self {
191 user_id: user_id.into(),
192 topic: topic.into(),
193 ..Self::default()
194 }
195 }
196
197 pub fn user_id_type(mut self, user_id_type: impl Into<String>) -> Self {
199 self.user_id_type = Some(user_id_type.into());
200 self
201 }
202
203 pub fn status(mut self, status: impl Into<String>) -> Self {
206 self.status = Some(status.into());
207 self
208 }
209
210 pub fn instance_code(mut self, instance_code: impl Into<String>) -> Self {
212 self.instance_code = Some(instance_code.into());
213 self
214 }
215
216 pub fn page_size(mut self, page_size: i32) -> Self {
218 self.page_size = Some(page_size);
219 self
220 }
221}
222
223#[derive(Debug, Clone, Default, PartialEq)]
227pub struct ApprovalTaskAction {
228 pub approval_code: String,
230 pub instance_code: String,
232 pub user_id: String,
234 pub task_id: String,
236 pub user_id_type: Option<String>,
238 pub comment: Option<String>,
240 pub form: Option<String>,
242}
243
244impl ApprovalTaskAction {
245 pub fn new(
247 approval_code: impl Into<String>,
248 instance_code: impl Into<String>,
249 user_id: impl Into<String>,
250 task_id: impl Into<String>,
251 ) -> Self {
252 Self {
253 approval_code: approval_code.into(),
254 instance_code: instance_code.into(),
255 user_id: user_id.into(),
256 task_id: task_id.into(),
257 user_id_type: None,
258 comment: None,
259 form: None,
260 }
261 }
262
263 pub fn comment(mut self, comment: impl Into<String>) -> Self {
265 self.comment = Some(comment.into());
266 self
267 }
268
269 pub fn user_id_type(mut self, user_id_type: impl Into<String>) -> Self {
271 self.user_id_type = Some(user_id_type.into());
272 self
273 }
274
275 pub fn form(mut self, form: impl Into<String>) -> Self {
277 self.form = Some(form.into());
278 self
279 }
280}
281
282pub type ApprovalTaskItem = crate::approval::approval::v4::task::query::TaskItemV4;
284
285#[derive(Clone)]
289pub struct WorkflowService {
290 config: Arc<Config>,
291}
292
293impl WorkflowService {
294 pub fn new(config: Config) -> Self {
296 Self {
297 config: Arc::new(config),
298 }
299 }
300
301 #[cfg(feature = "v1")]
302 pub fn v1(&self) -> crate::v1::TaskV1 {
304 crate::v1::TaskV1::new(self.config.clone())
305 }
306
307 #[cfg(feature = "v2")]
308 pub fn v2(&self) -> crate::v2::TaskV2 {
310 crate::v2::TaskV2::new(self.config.clone())
311 }
312
313 #[cfg(feature = "v2")]
315 pub async fn list_tasks_all(
316 &self,
317 query: WorkflowTaskListQuery,
318 ) -> SDKResult<Vec<crate::v2::task::models::TaskItem>> {
319 use crate::v2::task::list::ListTasksRequest;
320
321 let mut items = Vec::new();
322 let mut page_token: Option<String> = None;
323
324 loop {
325 let mut request = ListTasksRequest::new(self.config.clone())
326 .page_size(query.page_size.unwrap_or(MAX_PAGE_SIZE));
327
328 if let Some(tasklist_guid) = &query.tasklist_guid {
329 request = request.tasklist_guid(tasklist_guid.clone());
330 }
331 if let Some(section_guid) = &query.section_guid {
332 request = request.section_guid(section_guid.clone());
333 }
334 if let Some(filter) = &query.filter {
335 request = request.filter(filter.clone());
336 }
337 if let Some(sort) = &query.sort {
338 request = request.sort(sort.clone());
339 }
340 if let Some(user_type) = &query.user_type {
341 request = request.user_type(user_type.clone());
342 }
343 if let Some(token) = &page_token {
344 request = request.page_token(token.clone());
345 }
346
347 let response = request.execute().await?;
348 items.extend(response.items);
349
350 if !response.has_more {
351 break;
352 }
353 page_token = response.page_token;
354 }
355
356 Ok(items)
357 }
358
359 #[cfg(feature = "v2")]
361 pub async fn mutate_task(
362 &self,
363 task_guid: impl Into<String>,
364 mutation: WorkflowTaskMutation,
365 ) -> SDKResult<crate::v2::task::models::UpdateTaskResponse> {
366 use crate::v2::task::update::UpdateTaskRequest;
367
368 let mut request = UpdateTaskRequest::new(self.config.clone(), task_guid.into());
369 if let Some(summary) = mutation.summary {
370 request = request.summary(summary);
371 }
372 if let Some(description) = mutation.description {
373 request = request.description(description);
374 }
375 if let Some(due) = mutation.due {
376 request = request.due(due);
377 }
378 if let Some(priority) = mutation.priority {
379 request = request.priority(priority);
380 }
381 if let Some(assignee) = mutation.assignee {
382 request = request.assignee(assignee);
383 }
384 if let Some(status) = mutation.status {
385 request = request.status(status);
386 }
387
388 request.execute().await
389 }
390
391 #[cfg(feature = "v2")]
393 pub async fn complete_task(
394 &self,
395 task_guid: impl Into<String>,
396 ) -> SDKResult<crate::v2::task::models::CompleteTaskResponse> {
397 use crate::v2::task::complete::CompleteTaskRequest;
398
399 CompleteTaskRequest::new(self.config.clone(), task_guid.into())
400 .execute()
401 .await
402 }
403
404 #[cfg(feature = "v2")]
406 pub async fn reopen_task(
407 &self,
408 task_guid: impl Into<String>,
409 ) -> SDKResult<crate::v2::task::models::UncompleteTaskResponse> {
410 use crate::v2::task::uncomplete::UncompleteTaskRequest;
411
412 UncompleteTaskRequest::new(self.config.clone(), task_guid.into())
413 .execute()
414 .await
415 }
416
417 pub async fn query_approval_tasks(
419 &self,
420 query: ApprovalTaskQuery,
421 ) -> SDKResult<Vec<ApprovalTaskItem>> {
422 let mut items = Vec::new();
423 let mut page_token: Option<String> = None;
424
425 loop {
426 let mut request = crate::approval::approval::v4::task::query::QueryTaskRequestV4::new(
427 self.config.clone(),
428 )
429 .user_id(query.user_id.clone())
430 .topic(query.topic.clone())
431 .page_size(query.page_size.unwrap_or(MAX_PAGE_SIZE));
432
433 if let Some(user_id_type) = &query.user_id_type {
434 request = request.user_id_type(user_id_type.clone());
435 }
436 if let Some(token) = &page_token {
437 request = request.page_token(token.clone());
438 }
439
440 let response = request.execute().await?;
441 items.extend(response.tasks);
442
443 if !response.has_more.unwrap_or(false) {
444 break;
445 }
446 page_token = response.page_token;
447 }
448
449 if let Some(status) = &query.status {
450 items.retain(|item| item.status == *status);
451 }
452 if let Some(instance_code) = &query.instance_code {
453 items.retain(|item| item.instance_code == *instance_code);
454 }
455
456 Ok(items)
457 }
458
459 pub async fn approve_task(&self, action: ApprovalTaskAction) -> SDKResult<()> {
464 let mut request = crate::approval::approval::v4::task::approve::ApproveTaskRequestV4::new(
465 self.config.clone(),
466 )
467 .approval_code(action.approval_code)
468 .instance_code(action.instance_code)
469 .user_id(action.user_id)
470 .task_id(action.task_id);
471 if let Some(user_id_type) = action.user_id_type {
472 request = request.user_id_type(user_id_type);
473 }
474 if let Some(comment) = action.comment {
475 request = request.comment(comment);
476 }
477 if let Some(form) = action.form {
478 request = request.form(form);
479 }
480 request.execute().await?;
481 Ok(())
482 }
483
484 pub async fn reject_task(&self, action: ApprovalTaskAction) -> SDKResult<()> {
488 let mut request = crate::approval::approval::v4::task::reject::RejectTaskRequestV4::new(
489 self.config.clone(),
490 )
491 .approval_code(action.approval_code)
492 .instance_code(action.instance_code)
493 .user_id(action.user_id)
494 .task_id(action.task_id);
495 if let Some(user_id_type) = action.user_id_type {
496 request = request.user_id_type(user_id_type);
497 }
498 if let Some(comment) = action.comment {
499 request = request.comment(comment);
500 }
501 if let Some(form) = action.form {
502 request = request.form(form);
503 }
504 request.execute().await?;
505 Ok(())
506 }
507
508 pub async fn resubmit_task(&self, action: ApprovalTaskAction) -> SDKResult<()> {
512 let mut request =
513 crate::approval::approval::v4::task::resubmit::ResubmitTaskRequestV4::new(
514 self.config.clone(),
515 )
516 .approval_code(action.approval_code)
517 .instance_code(action.instance_code)
518 .user_id(action.user_id)
519 .task_id(action.task_id);
520 if let Some(user_id_type) = action.user_id_type {
521 request = request.user_id_type(user_id_type);
522 }
523 if let Some(comment) = action.comment {
524 request = request.comment(comment);
525 }
526 if let Some(form) = action.form {
527 request = request.form(form);
528 }
529 request.execute().await?;
530 Ok(())
531 }
532}
533
534#[cfg(test)]
535#[allow(unused_imports)]
536mod tests {
537 use super::*;
538 use serde_json::json;
539
540 #[test]
541 fn test_task_list_query_builder() {
542 let query = WorkflowTaskListQuery::for_tasklist("tasklist_123")
543 .section_guid("section_456")
544 .filter("status = incomplete")
545 .sort(json!([{"field": "due", "order": "asc"}]))
546 .user_type("open_id")
547 .page_size(50);
548
549 assert_eq!(query.tasklist_guid.as_deref(), Some("tasklist_123"));
550 assert_eq!(query.section_guid.as_deref(), Some("section_456"));
551 assert_eq!(query.filter.as_deref(), Some("status = incomplete"));
552 assert_eq!(query.user_type.as_deref(), Some("open_id"));
553 assert_eq!(query.page_size, Some(50));
554 }
555
556 #[test]
557 fn test_task_mutation_builder() {
558 let mutation = WorkflowTaskMutation::new()
559 .summary("完成项目文档")
560 .description("补齐 workflow helper")
561 .due("2026-09-30T23:59:59Z")
562 .priority(3)
563 .assignee("ou_xxx")
564 .status("in_progress");
565
566 assert_eq!(mutation.summary.as_deref(), Some("完成项目文档"));
567 assert_eq!(
568 mutation.description.as_deref(),
569 Some("补齐 workflow helper")
570 );
571 assert_eq!(mutation.due.as_deref(), Some("2026-09-30T23:59:59Z"));
572 assert_eq!(mutation.priority, Some(3));
573 assert_eq!(mutation.assignee.as_deref(), Some("ou_xxx"));
574 assert_eq!(mutation.status.as_deref(), Some("in_progress"));
575 }
576
577 #[test]
578 fn test_approval_task_query_builder() {
579 let query = ApprovalTaskQuery::new("ou_xxx", "1")
580 .user_id_type("open_id")
581 .status("PENDING")
582 .instance_code("instance_123")
583 .page_size(100);
584
585 assert_eq!(query.user_id, "ou_xxx");
586 assert_eq!(query.topic, "1");
587 assert_eq!(query.user_id_type.as_deref(), Some("open_id"));
588 assert_eq!(query.status.as_deref(), Some("PENDING"));
589 assert_eq!(query.instance_code.as_deref(), Some("instance_123"));
590 assert_eq!(query.page_size, Some(100));
591 }
592
593 #[test]
594 fn test_approval_task_action_builder() {
595 let action =
596 ApprovalTaskAction::new("approval_code", "instance_code", "ou_xxx", "task_123")
597 .user_id_type("open_id")
598 .comment("已确认")
599 .form("[{}]");
600
601 assert_eq!(action.approval_code, "approval_code");
602 assert_eq!(action.instance_code, "instance_code");
603 assert_eq!(action.user_id, "ou_xxx");
604 assert_eq!(action.task_id, "task_123");
605 assert_eq!(action.user_id_type.as_deref(), Some("open_id"));
606 assert_eq!(action.comment.as_deref(), Some("已确认"));
607 assert_eq!(action.form.as_deref(), Some("[{}]"));
608 }
609
610 #[tokio::test]
612 async fn test_approve_reject_resubmit_helpers_return_unit_on_success() {
613 use wiremock::matchers::{method, path};
614 use wiremock::{Mock, MockServer, ResponseTemplate};
615
616 let server = MockServer::start().await;
617 let paths = [
618 "/open-apis/approval/v4/tasks/approve",
619 "/open-apis/approval/v4/tasks/reject",
620 "/open-apis/approval/v4/tasks/resubmit",
621 ];
622 for p in paths {
623 Mock::given(method("POST"))
624 .and(path(p))
625 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
626 "code": 0,
627 "msg": "success",
628 "data": {}
629 })))
630 .mount(&server)
631 .await;
632 }
633
634 let service = WorkflowService::new(
635 Config::builder()
636 .app_id("ci_app_id")
637 .app_secret("ci_app_secret")
638 .base_url(server.uri())
639 .enable_token_cache(false)
640 .build(),
641 );
642
643 let action =
644 ApprovalTaskAction::new("approval_code", "instance_code", "ou_xxx", "task_123")
645 .user_id_type("open_id")
646 .comment("ok")
647 .form("[]");
648
649 service
650 .approve_task(action.clone())
651 .await
652 .expect("approve_task 应在飞书成功响应时返回 Ok(())");
653 service
654 .reject_task(action.clone())
655 .await
656 .expect("reject_task 应在飞书成功响应时返回 Ok(())");
657 service
658 .resubmit_task(action)
659 .await
660 .expect("resubmit_task 应在飞书成功响应时返回 Ok(())");
661
662 let received = server.received_requests().await.unwrap_or_default();
663 assert_eq!(
664 received.len(),
665 3,
666 "三个 helper 应各打一次飞书 approval v4 端点"
667 );
668 let hit: Vec<_> = received.iter().map(|r| r.url.path().to_string()).collect();
669 for p in paths {
670 assert!(
671 hit.iter().any(|h| h == p),
672 "missing request to {p}; got {hit:?}"
673 );
674 }
675 }
676
677 #[tokio::test]
683 async fn test_approve_task_helper_propagates_api_error() {
684 use openlark_core::error::CoreError;
685 use wiremock::matchers::{method, path};
686 use wiremock::{Mock, MockServer, ResponseTemplate};
687
688 let server = MockServer::start().await;
689 Mock::given(method("POST"))
690 .and(path("/open-apis/approval/v4/tasks/approve"))
691 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
692 "code": 99991400,
693 "msg": "invalid approval task"
694 })))
695 .mount(&server)
696 .await;
697
698 let service = WorkflowService::new(
699 Config::builder()
700 .app_id("ci_app_id")
701 .app_secret("ci_app_secret")
702 .base_url(server.uri())
703 .enable_token_cache(false)
704 .build(),
705 );
706
707 let err = service
708 .approve_task(ApprovalTaskAction::new(
709 "approval_code",
710 "instance_code",
711 "ou_xxx",
712 "task_123",
713 ))
714 .await
715 .expect_err("飞书失败响应应传播为 Err,不得伪装 Ok(())");
716 assert!(
717 matches!(err, CoreError::Validation { .. } | CoreError::Api(_)),
718 "expected Validation (missing data on non-zero code) or Api, got {err:?}"
719 );
720 let msg = err.to_string();
721 assert!(
722 msg.contains("服务器没有返回有效的数据") || msg.contains("invalid approval task"),
723 "error should surface leaf validation or Feishu msg, got: {err}"
724 );
725
726 let received = server.received_requests().await.unwrap_or_default();
727 assert_eq!(received.len(), 1);
728 assert_eq!(
729 received[0].url.path(),
730 "/open-apis/approval/v4/tasks/approve"
731 );
732 }
733}