pub use crate::approval::approval::v4::instance::add_cc::{
AddCcInstanceBodyV4, AddCcInstanceRequestV4, AddCcInstanceResponseV4,
};
pub use crate::approval::approval::v4::instance::detail::{
DetailInstanceRequestV4, DetailInstanceResponseV4, DetailInstanceTaskV4,
};
pub use crate::approval::approval::v4::instance::initiated::{
InitiatedInstanceItemV4, InitiatedInstanceRequestV4, InitiatedInstanceResponseV4,
InstanceSummaryV4,
};
pub use crate::approval::approval::v4::instance::recall::{
RecallInstanceBodyV4, RecallInstanceRequestV4, RecallInstanceResponseV4,
};
pub use crate::approval::approval::v4::instance::remind::{
RemindInstanceBodyV4, RemindInstanceRequestV4, RemindInstanceResponseV4,
};
pub use crate::approval::approval::v4::task::add_sign::{
AddSignTaskBodyV4, AddSignTaskRequestV4, AddSignTaskResponseV4,
};
pub use crate::approval::approval::v4::task::forward::{
ForwardTaskBodyV4, ForwardTaskRequestV4, ForwardTaskResponseV4,
};
pub use crate::approval::approval::v4::task::list::{
ListTaskItemV4, ListTaskRequestV4, ListTaskResponseV4, TaskSummaryV4,
};
pub use crate::approval::approval::v4::task::pass::{
PassTaskBodyV4, PassTaskRequestV4, PassTaskResponseV4,
};
pub use crate::approval::approval::v4::task::refuse::{
RefuseTaskBodyV4, RefuseTaskRequestV4, RefuseTaskResponseV4,
};
pub use crate::approval::approval::v4::task::rollback::{
RollbackTaskBodyV4, RollbackTaskRequestV4, RollbackTaskResponseV4,
};
use openlark_core::{SDKResult, config::Config};
use std::sync::Arc;
use crate::common::constants::MAX_PAGE_SIZE;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct WorkflowTaskListQuery {
pub tasklist_guid: Option<String>,
pub section_guid: Option<String>,
pub filter: Option<String>,
pub sort: Option<serde_json::Value>,
pub user_type: Option<String>,
pub page_size: Option<i32>,
}
impl WorkflowTaskListQuery {
pub fn for_tasklist(tasklist_guid: impl Into<String>) -> Self {
Self {
tasklist_guid: Some(tasklist_guid.into()),
..Self::default()
}
}
pub fn section_guid(mut self, section_guid: impl Into<String>) -> Self {
self.section_guid = Some(section_guid.into());
self
}
pub fn filter(mut self, filter: impl Into<String>) -> Self {
self.filter = Some(filter.into());
self
}
pub fn sort(mut self, sort: serde_json::Value) -> Self {
self.sort = Some(sort);
self
}
pub fn user_type(mut self, user_type: impl Into<String>) -> Self {
self.user_type = Some(user_type.into());
self
}
pub fn page_size(mut self, page_size: i32) -> Self {
self.page_size = Some(page_size);
self
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct WorkflowTaskMutation {
pub summary: Option<String>,
pub description: Option<String>,
pub due: Option<String>,
pub priority: Option<i32>,
pub assignee: Option<String>,
pub status: Option<String>,
}
impl WorkflowTaskMutation {
pub fn new() -> Self {
Self::default()
}
pub fn summary(mut self, summary: impl Into<String>) -> Self {
self.summary = Some(summary.into());
self
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn due(mut self, due: impl Into<String>) -> Self {
self.due = Some(due.into());
self
}
pub fn priority(mut self, priority: i32) -> Self {
self.priority = Some(priority);
self
}
pub fn assignee(mut self, assignee: impl Into<String>) -> Self {
self.assignee = Some(assignee.into());
self
}
pub fn status(mut self, status: impl Into<String>) -> Self {
self.status = Some(status.into());
self
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ApprovalTaskQuery {
pub user_id: String,
pub topic: String,
pub user_id_type: Option<String>,
pub status: Option<String>,
pub instance_code: Option<String>,
pub page_size: Option<i32>,
}
impl ApprovalTaskQuery {
pub fn new(user_id: impl Into<String>, topic: impl Into<String>) -> Self {
Self {
user_id: user_id.into(),
topic: topic.into(),
..Self::default()
}
}
pub fn user_id_type(mut self, user_id_type: impl Into<String>) -> Self {
self.user_id_type = Some(user_id_type.into());
self
}
pub fn status(mut self, status: impl Into<String>) -> Self {
self.status = Some(status.into());
self
}
pub fn instance_code(mut self, instance_code: impl Into<String>) -> Self {
self.instance_code = Some(instance_code.into());
self
}
pub fn page_size(mut self, page_size: i32) -> Self {
self.page_size = Some(page_size);
self
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ApprovalTaskAction {
pub approval_code: String,
pub instance_code: String,
pub user_id: String,
pub task_id: String,
pub user_id_type: Option<String>,
pub comment: Option<String>,
pub form: Option<String>,
}
impl ApprovalTaskAction {
pub fn new(
approval_code: impl Into<String>,
instance_code: impl Into<String>,
user_id: impl Into<String>,
task_id: impl Into<String>,
) -> Self {
Self {
approval_code: approval_code.into(),
instance_code: instance_code.into(),
user_id: user_id.into(),
task_id: task_id.into(),
user_id_type: None,
comment: None,
form: None,
}
}
pub fn comment(mut self, comment: impl Into<String>) -> Self {
self.comment = Some(comment.into());
self
}
pub fn user_id_type(mut self, user_id_type: impl Into<String>) -> Self {
self.user_id_type = Some(user_id_type.into());
self
}
pub fn form(mut self, form: impl Into<String>) -> Self {
self.form = Some(form.into());
self
}
}
pub type ApprovalTaskItem = crate::approval::approval::v4::task::query::TaskItemV4;
#[derive(Clone)]
pub struct WorkflowService {
config: Arc<Config>,
}
impl WorkflowService {
pub fn new(config: Config) -> Self {
Self {
config: Arc::new(config),
}
}
#[cfg(feature = "v1")]
pub fn v1(&self) -> crate::v1::TaskV1 {
crate::v1::TaskV1::new(self.config.clone())
}
#[cfg(feature = "v2")]
pub fn v2(&self) -> crate::v2::TaskV2 {
crate::v2::TaskV2::new(self.config.clone())
}
#[cfg(feature = "v2")]
pub async fn list_tasks_all(
&self,
query: WorkflowTaskListQuery,
) -> SDKResult<Vec<crate::v2::task::models::TaskItem>> {
use crate::v2::task::list::ListTasksRequest;
let mut items = Vec::new();
let mut page_token: Option<String> = None;
loop {
let mut request = ListTasksRequest::new(self.config.clone())
.page_size(query.page_size.unwrap_or(MAX_PAGE_SIZE));
if let Some(tasklist_guid) = &query.tasklist_guid {
request = request.tasklist_guid(tasklist_guid.clone());
}
if let Some(section_guid) = &query.section_guid {
request = request.section_guid(section_guid.clone());
}
if let Some(filter) = &query.filter {
request = request.filter(filter.clone());
}
if let Some(sort) = &query.sort {
request = request.sort(sort.clone());
}
if let Some(user_type) = &query.user_type {
request = request.user_type(user_type.clone());
}
if let Some(token) = &page_token {
request = request.page_token(token.clone());
}
let response = request.execute().await?;
items.extend(response.items);
if !response.has_more {
break;
}
page_token = response.page_token;
}
Ok(items)
}
#[cfg(feature = "v2")]
pub async fn mutate_task(
&self,
task_guid: impl Into<String>,
mutation: WorkflowTaskMutation,
) -> SDKResult<crate::v2::task::models::UpdateTaskResponse> {
use crate::v2::task::update::UpdateTaskRequest;
let mut request = UpdateTaskRequest::new(self.config.clone(), task_guid.into());
if let Some(summary) = mutation.summary {
request = request.summary(summary);
}
if let Some(description) = mutation.description {
request = request.description(description);
}
if let Some(due) = mutation.due {
request = request.due(due);
}
if let Some(priority) = mutation.priority {
request = request.priority(priority);
}
if let Some(assignee) = mutation.assignee {
request = request.assignee(assignee);
}
if let Some(status) = mutation.status {
request = request.status(status);
}
request.execute().await
}
#[cfg(feature = "v2")]
pub async fn complete_task(
&self,
task_guid: impl Into<String>,
) -> SDKResult<crate::v2::task::models::CompleteTaskResponse> {
use crate::v2::task::complete::CompleteTaskRequest;
CompleteTaskRequest::new(self.config.clone(), task_guid.into())
.execute()
.await
}
#[cfg(feature = "v2")]
pub async fn reopen_task(
&self,
task_guid: impl Into<String>,
) -> SDKResult<crate::v2::task::models::UncompleteTaskResponse> {
use crate::v2::task::uncomplete::UncompleteTaskRequest;
UncompleteTaskRequest::new(self.config.clone(), task_guid.into())
.execute()
.await
}
pub async fn query_approval_tasks(
&self,
query: ApprovalTaskQuery,
) -> SDKResult<Vec<ApprovalTaskItem>> {
let mut items = Vec::new();
let mut page_token: Option<String> = None;
loop {
let mut request = crate::approval::approval::v4::task::query::QueryTaskRequestV4::new(
self.config.clone(),
)
.user_id(query.user_id.clone())
.topic(query.topic.clone())
.page_size(query.page_size.unwrap_or(MAX_PAGE_SIZE));
if let Some(user_id_type) = &query.user_id_type {
request = request.user_id_type(user_id_type.clone());
}
if let Some(token) = &page_token {
request = request.page_token(token.clone());
}
let response = request.execute().await?;
items.extend(response.tasks);
if !response.has_more.unwrap_or(false) {
break;
}
page_token = response.page_token;
}
if let Some(status) = &query.status {
items.retain(|item| item.status == *status);
}
if let Some(instance_code) = &query.instance_code {
items.retain(|item| item.instance_code == *instance_code);
}
Ok(items)
}
pub async fn approve_task(&self, action: ApprovalTaskAction) -> SDKResult<()> {
let mut request = crate::approval::approval::v4::task::approve::ApproveTaskRequestV4::new(
self.config.clone(),
)
.approval_code(action.approval_code)
.instance_code(action.instance_code)
.user_id(action.user_id)
.task_id(action.task_id);
if let Some(user_id_type) = action.user_id_type {
request = request.user_id_type(user_id_type);
}
if let Some(comment) = action.comment {
request = request.comment(comment);
}
if let Some(form) = action.form {
request = request.form(form);
}
request.execute().await?;
Ok(())
}
pub async fn reject_task(&self, action: ApprovalTaskAction) -> SDKResult<()> {
let mut request = crate::approval::approval::v4::task::reject::RejectTaskRequestV4::new(
self.config.clone(),
)
.approval_code(action.approval_code)
.instance_code(action.instance_code)
.user_id(action.user_id)
.task_id(action.task_id);
if let Some(user_id_type) = action.user_id_type {
request = request.user_id_type(user_id_type);
}
if let Some(comment) = action.comment {
request = request.comment(comment);
}
if let Some(form) = action.form {
request = request.form(form);
}
request.execute().await?;
Ok(())
}
pub async fn resubmit_task(&self, action: ApprovalTaskAction) -> SDKResult<()> {
let mut request =
crate::approval::approval::v4::task::resubmit::ResubmitTaskRequestV4::new(
self.config.clone(),
)
.approval_code(action.approval_code)
.instance_code(action.instance_code)
.user_id(action.user_id)
.task_id(action.task_id);
if let Some(user_id_type) = action.user_id_type {
request = request.user_id_type(user_id_type);
}
if let Some(comment) = action.comment {
request = request.comment(comment);
}
if let Some(form) = action.form {
request = request.form(form);
}
request.execute().await?;
Ok(())
}
}
#[cfg(test)]
#[allow(unused_imports)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_task_list_query_builder() {
let query = WorkflowTaskListQuery::for_tasklist("tasklist_123")
.section_guid("section_456")
.filter("status = incomplete")
.sort(json!([{"field": "due", "order": "asc"}]))
.user_type("open_id")
.page_size(50);
assert_eq!(query.tasklist_guid.as_deref(), Some("tasklist_123"));
assert_eq!(query.section_guid.as_deref(), Some("section_456"));
assert_eq!(query.filter.as_deref(), Some("status = incomplete"));
assert_eq!(query.user_type.as_deref(), Some("open_id"));
assert_eq!(query.page_size, Some(50));
}
#[test]
fn test_task_mutation_builder() {
let mutation = WorkflowTaskMutation::new()
.summary("完成项目文档")
.description("补齐 workflow helper")
.due("2026-09-30T23:59:59Z")
.priority(3)
.assignee("ou_xxx")
.status("in_progress");
assert_eq!(mutation.summary.as_deref(), Some("完成项目文档"));
assert_eq!(
mutation.description.as_deref(),
Some("补齐 workflow helper")
);
assert_eq!(mutation.due.as_deref(), Some("2026-09-30T23:59:59Z"));
assert_eq!(mutation.priority, Some(3));
assert_eq!(mutation.assignee.as_deref(), Some("ou_xxx"));
assert_eq!(mutation.status.as_deref(), Some("in_progress"));
}
#[test]
fn test_approval_task_query_builder() {
let query = ApprovalTaskQuery::new("ou_xxx", "1")
.user_id_type("open_id")
.status("PENDING")
.instance_code("instance_123")
.page_size(100);
assert_eq!(query.user_id, "ou_xxx");
assert_eq!(query.topic, "1");
assert_eq!(query.user_id_type.as_deref(), Some("open_id"));
assert_eq!(query.status.as_deref(), Some("PENDING"));
assert_eq!(query.instance_code.as_deref(), Some("instance_123"));
assert_eq!(query.page_size, Some(100));
}
#[test]
fn test_approval_task_action_builder() {
let action =
ApprovalTaskAction::new("approval_code", "instance_code", "ou_xxx", "task_123")
.user_id_type("open_id")
.comment("已确认")
.form("[{}]");
assert_eq!(action.approval_code, "approval_code");
assert_eq!(action.instance_code, "instance_code");
assert_eq!(action.user_id, "ou_xxx");
assert_eq!(action.task_id, "task_123");
assert_eq!(action.user_id_type.as_deref(), Some("open_id"));
assert_eq!(action.comment.as_deref(), Some("已确认"));
assert_eq!(action.form.as_deref(), Some("[{}]"));
}
#[tokio::test]
async fn test_approve_reject_resubmit_helpers_return_unit_on_success() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let paths = [
"/open-apis/approval/v4/tasks/approve",
"/open-apis/approval/v4/tasks/reject",
"/open-apis/approval/v4/tasks/resubmit",
];
for p in paths {
Mock::given(method("POST"))
.and(path(p))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"code": 0,
"msg": "success",
"data": {}
})))
.mount(&server)
.await;
}
let service = WorkflowService::new(
Config::builder()
.app_id("ci_app_id")
.app_secret("ci_app_secret")
.base_url(server.uri())
.enable_token_cache(false)
.build(),
);
let action =
ApprovalTaskAction::new("approval_code", "instance_code", "ou_xxx", "task_123")
.user_id_type("open_id")
.comment("ok")
.form("[]");
service
.approve_task(action.clone())
.await
.expect("approve_task 应在飞书成功响应时返回 Ok(())");
service
.reject_task(action.clone())
.await
.expect("reject_task 应在飞书成功响应时返回 Ok(())");
service
.resubmit_task(action)
.await
.expect("resubmit_task 应在飞书成功响应时返回 Ok(())");
let received = server.received_requests().await.unwrap_or_default();
assert_eq!(
received.len(),
3,
"三个 helper 应各打一次飞书 approval v4 端点"
);
let hit: Vec<_> = received.iter().map(|r| r.url.path().to_string()).collect();
for p in paths {
assert!(
hit.iter().any(|h| h == p),
"missing request to {p}; got {hit:?}"
);
}
}
#[tokio::test]
async fn test_approve_task_helper_propagates_api_error() {
use openlark_core::error::CoreError;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/open-apis/approval/v4/tasks/approve"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"code": 99991400,
"msg": "invalid approval task"
})))
.mount(&server)
.await;
let service = WorkflowService::new(
Config::builder()
.app_id("ci_app_id")
.app_secret("ci_app_secret")
.base_url(server.uri())
.enable_token_cache(false)
.build(),
);
let err = service
.approve_task(ApprovalTaskAction::new(
"approval_code",
"instance_code",
"ou_xxx",
"task_123",
))
.await
.expect_err("飞书失败响应应传播为 Err,不得伪装 Ok(())");
assert!(
matches!(err, CoreError::Validation { .. } | CoreError::Api(_)),
"expected Validation (missing data on non-zero code) or Api, got {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("服务器没有返回有效的数据") || msg.contains("invalid approval task"),
"error should surface leaf validation or Feishu msg, got: {err}"
);
let received = server.received_requests().await.unwrap_or_default();
assert_eq!(received.len(), 1);
assert_eq!(
received[0].url.path(),
"/open-apis/approval/v4/tasks/approve"
);
}
}