Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,26 @@
[package]
name = "nomifun-webhook"
version.workspace = true
edition.workspace = true
[dependencies]
nomifun-common.workspace = true
nomifun-db.workspace = true
nomifun-api-types.workspace = true
nomifun-auth.workspace = true
nomifun-requirement.workspace = true
axum.workspace = true
tokio.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
async-trait.workspace = true
reqwest.workspace = true
hmac.workspace = true
sha2.workspace = true
base64.workspace = true
chrono.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
sqlx = { workspace = true }
@@ -0,0 +1,16 @@
use thiserror::Error;
/// Errors from outbound webhook delivery. These are surfaced to clients only via
/// the explicit `/test` endpoint (as a 502); the completion notifier logs and
/// swallows them so a failing webhook never affects requirement state.
#[derive(Debug, Error)]
pub enum WebhookError {
#[error("signing failed: {0}")]
Sign(String),
#[error("request failed: {0}")]
Http(String),
#[error("remote rejected the webhook: {0}")]
Remote(String),
}
@@ -0,0 +1,20 @@
//! Webhook management + AutoWork completion notifications.
//!
//! - CRUD over reusable outbound webhooks (v1: Lark/飞书 custom bots) and per-tag
//! settings (bound webhook + description) layered over the implicit tags.
//! - `CompletionNotifierImpl` implements `nomifun_requirement::CompletionNotifier`
//! so a requirement reaching a terminal state notifies its tag's bound webhook.
pub mod error;
pub mod notifier;
pub mod routes;
pub mod sender;
pub mod service;
pub mod state;
pub use error::WebhookError;
pub use notifier::CompletionNotifierImpl;
pub use routes::webhook_routes;
pub use sender::{DefaultWebhookSender, WebhookSender};
pub use service::WebhookService;
pub use state::WebhookRouterState;
@@ -0,0 +1,148 @@
//! Completion notifier: implements `nomifun_requirement::CompletionNotifier` by
//! looking up the requirement's tag → bound webhook and sending a notification.
//!
//! Dependency direction: this crate depends on `nomifun-requirement` (for the
//! trait); `nomifun-requirement` does NOT depend on this crate. Mirrors how
//! `nomifun-idmm` implements `nomifun_requirement::IdmmHandle`.
use std::sync::Arc;
use async_trait::async_trait;
use nomifun_api_types::WebhookPlatform;
use nomifun_db::models::RequirementRow;
use nomifun_db::{ITagSettingRepository, IWebhookRepository};
use nomifun_requirement::CompletionNotifier;
use crate::sender::WebhookSender;
/// Truncate a content snippet for the notification card (keeps cards compact).
const MAX_CONTENT_CHARS: usize = 500;
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let truncated: String = s.chars().take(max).collect();
format!("{truncated}")
}
/// Human-readable completion status for the 【完成状态】 field.
fn status_label(status: &str) -> &'static str {
match status {
"done" => "已完成 (done)",
"failed" => "失败 (failed)",
"cancelled" => "已取消 (cancelled)",
_ => "完成 (completed)",
}
}
/// Whether `status` is in the per-tag allowed event set.
pub fn event_allowed(status: &str, events: &[String]) -> bool {
events.iter().any(|e| e == status)
}
pub struct CompletionNotifierImpl {
tag_settings: Arc<dyn ITagSettingRepository>,
webhooks: Arc<dyn IWebhookRepository>,
sender: Arc<dyn WebhookSender>,
}
impl CompletionNotifierImpl {
pub fn new(
tag_settings: Arc<dyn ITagSettingRepository>,
webhooks: Arc<dyn IWebhookRepository>,
sender: Arc<dyn WebhookSender>,
) -> Self {
Self {
tag_settings,
webhooks,
sender,
}
}
pub fn into_arc(self) -> Arc<dyn CompletionNotifier> {
Arc::new(self)
}
/// Resolve the bound + enabled webhook for `tag` plus its allowed event set,
/// if any binding exists.
async fn resolve_webhook(&self, tag: &str) -> Option<(nomifun_db::models::WebhookRow, Vec<String>)> {
let setting = self.tag_settings.get(tag).await.ok().flatten()?;
let events: Vec<String> = setting
.notify_events
.split(',')
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
let webhook_id = setting.webhook_id?;
let webhook = self.webhooks.get_by_id(webhook_id).await.ok().flatten()?;
webhook.enabled.then_some((webhook, events))
}
}
#[async_trait]
impl CompletionNotifier for CompletionNotifierImpl {
async fn notify_completion(&self, requirement: &RequirementRow) {
let Some((webhook, events)) = self.resolve_webhook(&requirement.tag).await else {
return; // no binding / disabled / missing → silent skip
};
if !event_allowed(&requirement.status, &events) {
return; // this event isn't in the tag's allowed set → silent skip
}
// Template: 【需求id】【需求名】【需求内容】【完成状态】【完成记录(报告)】
let fields = vec![
("需求id".to_string(), requirement.id.to_string()),
("需求名".to_string(), requirement.title.clone()),
(
"需求内容".to_string(),
truncate(&requirement.content, MAX_CONTENT_CHARS),
),
("完成状态".to_string(), status_label(&requirement.status).to_string()),
(
"完成记录(报告)".to_string(),
requirement
.completion_note
.as_deref()
.map(|n| truncate(n, MAX_CONTENT_CHARS))
.unwrap_or_else(|| "-".to_string()),
),
];
let title = format!("需求{}: {}", status_label(&requirement.status), requirement.title);
if let Err(e) = self
.sender
.send_card(
WebhookPlatform::from_db(&webhook.platform),
&webhook.url,
webhook.secret.as_deref(),
&title,
&fields,
)
.await
{
// Best-effort: log + swallow. A failing webhook must never affect
// requirement state (and this runs on a detached task anyway).
tracing::warn!(
webhook_id = %webhook.id,
requirement_id = %requirement.id,
error = %e,
"completion webhook delivery failed"
);
}
}
}
#[cfg(test)]
mod tests {
use super::event_allowed;
#[test]
fn allows_when_status_in_set() {
assert!(event_allowed("done", &["done".to_string(), "failed".to_string()]));
assert!(!event_allowed("needs_review", &["done".to_string(), "failed".to_string()]));
}
#[test]
fn empty_set_allows_nothing() {
assert!(!event_allowed("done", &[]));
}
}
@@ -0,0 +1,109 @@
//! Webhook HTTP routes. Handlers do request/response transformation only; all
//! logic lives in `WebhookService`. Auth is layered externally in nomifun-app
//! (mirrors the requirement / idmm routes).
use axum::Router;
use axum::extract::rejection::JsonRejection;
use axum::extract::{Extension, Json, Path, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use nomifun_api_types::{
ApiResponse, CreateWebhookRequest, TagSetting, UpdateWebhookRequest, UpsertTagSettingRequest, Webhook,
};
use nomifun_auth::CurrentUser;
use nomifun_common::AppError;
use crate::state::WebhookRouterState;
pub fn webhook_routes(state: WebhookRouterState) -> Router {
Router::new()
.route("/api/webhooks", get(list_webhooks).post(create_webhook))
.route(
"/api/webhooks/{id}",
get(get_webhook).put(update_webhook).delete(delete_webhook),
)
.route("/api/webhooks/{id}/test", post(test_webhook))
.route("/api/tags/{tag}/settings", get(get_tag_setting).put(upsert_tag_setting))
.with_state(state)
}
/// Parse the `{id}` path segment (always a string on the wire) into the i64
/// webhook primary key, surfacing a clean 400 on malformed input.
fn parse_id(id: &str) -> Result<i64, AppError> {
id.parse::<i64>()
.map_err(|_| AppError::BadRequest(format!("invalid webhook id: {id}")))
}
async fn list_webhooks(
State(state): State<WebhookRouterState>,
Extension(_user): Extension<CurrentUser>,
) -> Result<Json<ApiResponse<Vec<Webhook>>>, AppError> {
Ok(Json(ApiResponse::ok(state.service.list().await?)))
}
async fn get_webhook(
State(state): State<WebhookRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<Webhook>>, AppError> {
Ok(Json(ApiResponse::ok(state.service.get(parse_id(&id)?).await?)))
}
async fn create_webhook(
State(state): State<WebhookRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<CreateWebhookRequest>, JsonRejection>,
) -> Result<(StatusCode, Json<ApiResponse<Webhook>>), AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let created = state.service.create(req).await?;
Ok((StatusCode::CREATED, Json(ApiResponse::ok(created))))
}
async fn update_webhook(
State(state): State<WebhookRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<String>,
body: Result<Json<UpdateWebhookRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<Webhook>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
Ok(Json(ApiResponse::ok(state.service.update(parse_id(&id)?, req).await?)))
}
async fn delete_webhook(
State(state): State<WebhookRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<()>>, AppError> {
state.service.delete(parse_id(&id)?).await?;
Ok(Json(ApiResponse::success()))
}
async fn test_webhook(
State(state): State<WebhookRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<()>>, AppError> {
state.service.test(parse_id(&id)?).await?;
Ok(Json(ApiResponse::success()))
}
async fn get_tag_setting(
State(state): State<WebhookRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(tag): Path<String>,
) -> Result<Json<ApiResponse<TagSetting>>, AppError> {
Ok(Json(ApiResponse::ok(state.service.get_tag_setting(&tag).await?)))
}
async fn upsert_tag_setting(
State(state): State<WebhookRouterState>,
Extension(_user): Extension<CurrentUser>,
Path(tag): Path<String>,
body: Result<Json<UpsertTagSettingRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<TagSetting>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
Ok(Json(ApiResponse::ok(
state.service.upsert_tag_setting(&tag, req).await?,
)))
}
@@ -0,0 +1,236 @@
//! Outbound webhook delivery. v1 supports Lark/飞书 custom bots.
//!
//! Signing + payload construction are pure functions so they can be unit-tested
//! without a live HTTP server; `send_card` performs the actual POST.
use base64::Engine;
use hmac::{Hmac, Mac};
use nomifun_api_types::WebhookPlatform;
use serde_json::{Value, json};
use sha2::Sha256;
use crate::error::WebhookError;
type HmacSha256 = Hmac<Sha256>;
/// Abstraction over a webhook platform's "send a notification card" operation.
/// Kept as a trait so the completion notifier + tests can swap in a mock, and so
/// future platforms can be added without touching callers.
#[async_trait::async_trait]
pub trait WebhookSender: Send + Sync {
/// Send a titled card with `(label, value)` field rows to `url`. When
/// `secret` is set, the request is signed (Lark 加签). `platform` selects
/// the payload shape (Lark interactive card / Slack text / generic HTTP JSON).
async fn send_card(
&self,
platform: WebhookPlatform,
url: &str,
secret: Option<&str>,
title: &str,
fields: &[(String, String)],
) -> Result<(), WebhookError>;
}
/// Platform-dispatching sender: builds the right payload per platform
/// (Lark interactive card / Slack text / generic HTTP JSON) and POSTs it.
#[derive(Clone)]
pub struct DefaultWebhookSender {
client: reqwest::Client,
}
impl Default for DefaultWebhookSender {
fn default() -> Self {
Self {
client: reqwest::Client::new(),
}
}
}
impl DefaultWebhookSender {
pub fn new() -> Self {
Self::default()
}
}
/// Compute the Lark custom-bot signature: `base64(HMAC-SHA256(key = "{ts}\n{secret}", msg = ""))`.
pub fn lark_sign(secret: &str, timestamp: i64) -> Result<String, WebhookError> {
let string_to_sign = format!("{timestamp}\n{secret}");
let mut mac =
HmacSha256::new_from_slice(string_to_sign.as_bytes()).map_err(|e| WebhookError::Sign(e.to_string()))?;
mac.update(b"");
let code = mac.finalize().into_bytes();
Ok(base64::engine::general_purpose::STANDARD.encode(code))
}
/// Build the Lark interactive-card message body (without signing fields).
pub fn build_lark_card(title: &str, fields: &[(String, String)]) -> Value {
let elements: Vec<Value> = fields
.iter()
.map(|(label, value)| {
json!({
"tag": "div",
"text": { "tag": "lark_md", "content": format!("**{label}**\n{value}") }
})
})
.collect();
json!({
"msg_type": "interactive",
"card": {
"config": { "wide_screen_mode": true },
"header": {
"title": { "tag": "plain_text", "content": title },
"template": "blue"
},
"elements": elements
}
})
}
/// Build the full request body, adding `timestamp`/`sign` when a secret is set.
pub fn build_lark_body(
secret: Option<&str>,
timestamp: i64,
title: &str,
fields: &[(String, String)],
) -> Result<Value, WebhookError> {
let mut body = build_lark_card(title, fields);
if let Some(secret) = secret.filter(|s| !s.is_empty()) {
let sign = lark_sign(secret, timestamp)?;
body["timestamp"] = json!(timestamp.to_string());
body["sign"] = json!(sign);
}
Ok(body)
}
/// Build a Slack incoming-webhook body: a single text blob with title + field lines.
pub fn build_slack_body(title: &str, fields: &[(String, String)]) -> Value {
let mut text = format!("*{title}*");
for (label, value) in fields {
text.push_str(&format!("\n*{label}*: {value}"));
}
json!({ "text": text })
}
/// Build a generic HTTP JSON body: structured title + fields so any consumer can parse it.
pub fn build_http_body(title: &str, fields: &[(String, String)]) -> Value {
let field_objs: Vec<Value> = fields
.iter()
.map(|(label, value)| json!({ "label": label, "value": value }))
.collect();
json!({ "title": title, "fields": field_objs })
}
#[async_trait::async_trait]
impl WebhookSender for DefaultWebhookSender {
async fn send_card(
&self,
platform: WebhookPlatform,
url: &str,
secret: Option<&str>,
title: &str,
fields: &[(String, String)],
) -> Result<(), WebhookError> {
let body = match platform {
WebhookPlatform::Lark => {
let timestamp = chrono::Utc::now().timestamp();
build_lark_body(secret, timestamp, title, fields)?
}
WebhookPlatform::Slack => build_slack_body(title, fields),
WebhookPlatform::Http => build_http_body(title, fields),
};
let resp = self
.client
.post(url)
.json(&body)
.send()
.await
.map_err(|e| WebhookError::Http(e.to_string()))?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(WebhookError::Remote(format!("HTTP {status}: {text}")));
}
// Lark replies {"code":0,...} (or legacy {"StatusCode":0,...}) on success;
// Slack/HTTP treat any 2xx as success (response body is free-form).
if matches!(platform, WebhookPlatform::Lark) {
let parsed: Value = serde_json::from_str(&text).unwrap_or_else(|_| json!({}));
let code = parsed
.get("code")
.and_then(Value::as_i64)
.or_else(|| parsed.get("StatusCode").and_then(Value::as_i64))
.unwrap_or(0);
if code != 0 {
return Err(WebhookError::Remote(format!("lark code {code}: {text}")));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sign_is_deterministic_and_base64() {
let a = lark_sign("secret", 1_700_000_000).unwrap();
let b = lark_sign("secret", 1_700_000_000).unwrap();
assert_eq!(a, b);
assert!(!a.is_empty());
// valid base64 decodes to 32 bytes (sha256 output)
let decoded = base64::engine::general_purpose::STANDARD.decode(&a).unwrap();
assert_eq!(decoded.len(), 32);
}
#[test]
fn sign_changes_with_timestamp() {
let a = lark_sign("secret", 1).unwrap();
let b = lark_sign("secret", 2).unwrap();
assert_ne!(a, b);
}
#[test]
fn body_without_secret_has_no_sign() {
let fields = [("需求名".to_string(), "build X".to_string())];
let body = build_lark_body(None, 123, "title", &fields).unwrap();
assert_eq!(body["msg_type"], "interactive");
assert!(body.get("sign").is_none());
assert!(body.get("timestamp").is_none());
let content = body["card"]["elements"][0]["text"]["content"].as_str().unwrap();
assert!(content.contains("需求名"));
assert!(content.contains("build X"));
}
#[test]
fn body_with_secret_includes_sign_and_timestamp() {
let body = build_lark_body(Some("s"), 999, "t", &[]).unwrap();
assert_eq!(body["timestamp"], "999");
assert!(body["sign"].as_str().is_some_and(|s| !s.is_empty()));
}
#[test]
fn empty_secret_is_treated_as_unsigned() {
let body = build_lark_body(Some(""), 999, "t", &[]).unwrap();
assert!(body.get("sign").is_none());
}
#[test]
fn slack_body_has_text_with_title_and_fields() {
let fields = [("需求名".to_string(), "build X".to_string())];
let body = build_slack_body("标题", &fields);
let text = body["text"].as_str().unwrap();
assert!(text.contains("标题"));
assert!(text.contains("需求名"));
assert!(text.contains("build X"));
}
#[test]
fn http_body_is_structured_json() {
let fields = [("a".to_string(), "1".to_string()), ("b".to_string(), "2".to_string())];
let body = build_http_body("T", &fields);
assert_eq!(body["title"], "T");
assert_eq!(body["fields"][0]["label"], "a");
assert_eq!(body["fields"][0]["value"], "1");
assert_eq!(body["fields"][1]["label"], "b");
}
}
@@ -0,0 +1,227 @@
//! Business logic for webhook CRUD + per-tag settings. No axum imports.
use std::sync::Arc;
use nomifun_api_types::{
CreateWebhookRequest, TagSetting, UpdateWebhookRequest, UpsertTagSettingRequest, Webhook, WebhookPlatform,
};
use nomifun_common::{AppError, now_ms};
use nomifun_db::models::{TagSettingRow, WebhookRow};
use nomifun_db::{ITagSettingRepository, IWebhookRepository};
use crate::sender::WebhookSender;
/// Map a DB row to the client DTO (dropping the secret; exposing `has_secret`).
fn row_to_dto(row: &WebhookRow) -> Webhook {
Webhook {
id: row.id,
name: row.name.clone(),
platform: WebhookPlatform::from_db(&row.platform),
url: row.url.clone(),
description: row.description.clone(),
has_secret: row.secret.as_deref().is_some_and(|s| !s.is_empty()),
enabled: row.enabled,
created_at: row.created_at,
updated_at: row.updated_at,
}
}
fn tag_setting_to_dto(row: &TagSettingRow) -> TagSetting {
TagSetting {
tag: row.tag.clone(),
webhook_id: row.webhook_id,
description: row.description.clone(),
notify_events: row.notify_events.split(',').filter(|s| !s.is_empty()).map(str::to_string).collect(),
}
}
/// Default notification event set (all three), matching the column default and
/// the historical "fire on every completion transition" behavior.
fn default_events() -> Vec<String> {
vec!["done".into(), "failed".into(), "needs_review".into()]
}
#[derive(Clone)]
pub struct WebhookService {
webhooks: Arc<dyn IWebhookRepository>,
tag_settings: Arc<dyn ITagSettingRepository>,
sender: Arc<dyn WebhookSender>,
}
impl WebhookService {
pub fn new(
webhooks: Arc<dyn IWebhookRepository>,
tag_settings: Arc<dyn ITagSettingRepository>,
sender: Arc<dyn WebhookSender>,
) -> Self {
Self {
webhooks,
tag_settings,
sender,
}
}
// ── Webhook CRUD ────────────────────────────────────────────────
pub async fn list(&self) -> Result<Vec<Webhook>, AppError> {
let rows = self.webhooks.list_all().await?;
Ok(rows.iter().map(row_to_dto).collect())
}
pub async fn get(&self, id: i64) -> Result<Webhook, AppError> {
let row = self
.webhooks
.get_by_id(id)
.await?
.ok_or_else(|| AppError::NotFound(format!("webhook {id}")))?;
Ok(row_to_dto(&row))
}
pub async fn create(&self, req: CreateWebhookRequest) -> Result<Webhook, AppError> {
if req.name.trim().is_empty() {
return Err(AppError::BadRequest("name must not be empty".into()));
}
if req.url.trim().is_empty() {
return Err(AppError::BadRequest("url must not be empty".into()));
}
let now = now_ms();
let mut row = WebhookRow {
id: 0, // ignored by insert(); the DB assigns the real id
name: req.name,
platform: req.platform.as_db().to_string(),
url: req.url,
secret: req.secret.filter(|s| !s.is_empty()),
description: req.description,
enabled: req.enabled.unwrap_or(true),
created_at: now,
updated_at: now,
};
row.id = self.webhooks.insert(&row).await?;
Ok(row_to_dto(&row))
}
pub async fn update(&self, id: i64, req: UpdateWebhookRequest) -> Result<Webhook, AppError> {
let mut row = self
.webhooks
.get_by_id(id)
.await?
.ok_or_else(|| AppError::NotFound(format!("webhook {id}")))?;
if let Some(name) = req.name {
if name.trim().is_empty() {
return Err(AppError::BadRequest("name must not be empty".into()));
}
row.name = name;
}
if let Some(url) = req.url {
if url.trim().is_empty() {
return Err(AppError::BadRequest("url must not be empty".into()));
}
row.url = url;
}
if let Some(platform) = req.platform {
row.platform = platform.as_db().to_string();
}
if let Some(description) = req.description {
row.description = description;
}
// `Some(Some(v))` sets, `Some(None)` clears, `None` keeps current.
if let Some(secret) = req.secret {
row.secret = secret.filter(|s| !s.is_empty());
}
if let Some(enabled) = req.enabled {
row.enabled = enabled;
}
row.updated_at = now_ms();
self.webhooks.update(&row).await?;
Ok(row_to_dto(&row))
}
pub async fn delete(&self, id: i64) -> Result<(), AppError> {
self.webhooks.delete(id).await?;
Ok(())
}
/// Send a sample card to verify the endpoint works. Surfaces send errors to
/// the caller as a 502 (this is the only path that exposes webhook errors).
pub async fn test(&self, id: i64) -> Result<(), AppError> {
let row = self
.webhooks
.get_by_id(id)
.await?
.ok_or_else(|| AppError::NotFound(format!("webhook {id}")))?;
let fields = vec![
("Nomi".to_string(), "Webhook test message".to_string()),
("Webhook".to_string(), row.name.clone()),
];
self.sender
.send_card(
WebhookPlatform::from_db(&row.platform),
&row.url,
row.secret.as_deref(),
"Nomi Webhook Test",
&fields,
)
.await
.map_err(|e| AppError::BadGateway(e.to_string()))
}
// ── Tag settings ────────────────────────────────────────────────
pub async fn get_tag_setting(&self, tag: &str) -> Result<TagSetting, AppError> {
match self.tag_settings.get(tag).await? {
Some(row) => Ok(tag_setting_to_dto(&row)),
// A tag with no settings row yet → return an empty (unbound) default
// so the client always gets a consistent shape.
None => Ok(TagSetting {
tag: tag.to_string(),
webhook_id: None,
description: String::new(),
notify_events: default_events(),
}),
}
}
pub async fn list_tag_settings(&self) -> Result<Vec<TagSetting>, AppError> {
let rows = self.tag_settings.list_all().await?;
Ok(rows.iter().map(tag_setting_to_dto).collect())
}
pub async fn upsert_tag_setting(&self, tag: &str, req: UpsertTagSettingRequest) -> Result<TagSetting, AppError> {
if tag.trim().is_empty() {
return Err(AppError::BadRequest("tag must not be empty".into()));
}
// Merge onto the existing row so a partial update keeps other fields.
let existing = self.tag_settings.get(tag).await?;
let webhook_id = match req.webhook_id {
Some(v) => v, // Some(Some)=bind, Some(None)=clear
None => existing.as_ref().and_then(|r| r.webhook_id),
};
// If binding a webhook, verify it exists (clean 400 vs a dangling id).
if let Some(wh_id) = webhook_id
&& self.webhooks.get_by_id(wh_id).await?.is_none()
{
return Err(AppError::BadRequest(format!("webhook {wh_id} does not exist")));
}
let description = req
.description
.or_else(|| existing.as_ref().map(|r| r.description.clone()))
.unwrap_or_default();
let events = req
.notify_events
.or_else(|| {
existing
.as_ref()
.map(|r| r.notify_events.split(',').filter(|s| !s.is_empty()).map(str::to_string).collect())
})
.unwrap_or_else(default_events);
let row = TagSettingRow {
tag: tag.to_string(),
webhook_id,
description,
notify_events: events.join(","),
updated_at: now_ms(),
};
self.tag_settings.upsert(&row).await?;
Ok(tag_setting_to_dto(&row))
}
}
@@ -0,0 +1,7 @@
use crate::service::WebhookService;
/// Router state for the webhook + tag-settings endpoints.
#[derive(Clone)]
pub struct WebhookRouterState {
pub service: WebhookService,
}
@@ -0,0 +1,156 @@
//! Tests for `CompletionNotifierImpl`: routes a finished requirement to its tag's
//! bound + enabled webhook, skips otherwise. Uses real in-memory repos + a mock sender.
use std::sync::Arc;
use std::sync::Mutex;
use nomifun_api_types::WebhookPlatform;
use nomifun_db::models::{RequirementRow, TagSettingRow, WebhookRow};
use nomifun_db::{
ITagSettingRepository, IWebhookRepository, SqliteTagSettingRepository, SqliteWebhookRepository,
init_database_memory,
};
use nomifun_requirement::CompletionNotifier;
use nomifun_webhook::{CompletionNotifierImpl, WebhookSender};
#[derive(Default)]
struct RecordingSender {
calls: Mutex<Vec<Vec<(String, String)>>>, // fields per call
}
#[async_trait::async_trait]
impl WebhookSender for RecordingSender {
async fn send_card(
&self,
_platform: WebhookPlatform,
_url: &str,
_secret: Option<&str>,
_title: &str,
fields: &[(String, String)],
) -> Result<(), nomifun_webhook::WebhookError> {
self.calls.lock().unwrap().push(fields.to_vec());
Ok(())
}
}
fn requirement(tag: &str) -> RequirementRow {
RequirementRow {
id: 1,
title: "Build the thing".into(),
content: "Implement feature X".into(),
tag: tag.into(),
order_key: "1".into(),
sort_seq: "00000001".into(),
status: "done".into(),
priority: 0,
completion_note: Some("did it".into()),
owner_session_id: None,
owner_kind: None,
claimed_at: None,
lease_expires_at: None,
started_at: None,
completed_at: Some(1),
attempt_count: 1,
created_by: "user".into(),
extra: "{}".into(),
created_at: 0,
updated_at: 1,
}
}
struct Ctx {
webhooks: Arc<dyn IWebhookRepository>,
tags: Arc<dyn ITagSettingRepository>,
sender: Arc<RecordingSender>,
}
async fn ctx() -> Ctx {
let db = init_database_memory().await.unwrap();
let webhooks: Arc<dyn IWebhookRepository> = Arc::new(SqliteWebhookRepository::new(db.pool().clone()));
let tags: Arc<dyn ITagSettingRepository> = Arc::new(SqliteTagSettingRepository::new(db.pool().clone()));
Box::leak(Box::new(db));
Ctx {
webhooks,
tags,
sender: Arc::new(RecordingSender::default()),
}
}
async fn add_webhook(ctx: &Ctx, enabled: bool) -> i64 {
ctx.webhooks
.insert(&WebhookRow {
id: 0, // ignored by insert(); DB assigns the real id
name: "bot".into(),
platform: "lark".into(),
url: "https://example.com/hook".into(),
secret: None,
description: String::new(),
enabled,
created_at: 0,
updated_at: 0,
})
.await
.unwrap()
}
async fn bind_tag(ctx: &Ctx, tag: &str, webhook_id: Option<i64>) {
ctx.tags
.upsert(&TagSettingRow {
tag: tag.into(),
webhook_id,
description: String::new(),
notify_events: "done,failed,needs_review".into(),
updated_at: 0,
})
.await
.unwrap();
}
fn notifier(ctx: &Ctx) -> CompletionNotifierImpl {
CompletionNotifierImpl::new(ctx.tags.clone(), ctx.webhooks.clone(), ctx.sender.clone())
}
#[tokio::test]
async fn notifies_bound_enabled_webhook_with_template_fields() {
let ctx = ctx().await;
let wh_id = add_webhook(&ctx, true).await;
bind_tag(&ctx, "alpha", Some(wh_id)).await;
notifier(&ctx).notify_completion(&requirement("alpha")).await;
let calls = ctx.sender.calls.lock().unwrap();
assert_eq!(calls.len(), 1, "bound + enabled → one send");
let labels: Vec<&str> = calls[0].iter().map(|(l, _)| l.as_str()).collect();
// Template: 【需求id】【需求名】【需求内容】【完成状态】【完成记录(报告)】
assert!(labels.contains(&"需求id"));
assert!(labels.contains(&"需求名"));
assert!(labels.contains(&"需求内容"));
assert!(labels.contains(&"完成状态"));
assert!(labels.contains(&"完成记录(报告)"));
}
#[tokio::test]
async fn skips_when_tag_unbound() {
let ctx = ctx().await;
add_webhook(&ctx, true).await;
// no bind_tag → tag "alpha" has no setting
notifier(&ctx).notify_completion(&requirement("alpha")).await;
assert!(ctx.sender.calls.lock().unwrap().is_empty());
}
#[tokio::test]
async fn skips_when_webhook_disabled() {
let ctx = ctx().await;
let wh_id = add_webhook(&ctx, false).await; // disabled
bind_tag(&ctx, "alpha", Some(wh_id)).await;
notifier(&ctx).notify_completion(&requirement("alpha")).await;
assert!(ctx.sender.calls.lock().unwrap().is_empty());
}
#[tokio::test]
async fn skips_when_binding_has_no_webhook() {
let ctx = ctx().await;
bind_tag(&ctx, "alpha", None).await; // setting exists but no webhook bound
notifier(&ctx).notify_completion(&requirement("alpha")).await;
assert!(ctx.sender.calls.lock().unwrap().is_empty());
}
@@ -0,0 +1,187 @@
//! Integration tests for `WebhookService` (CRUD + tag settings + test) using a
//! real in-memory DB and a recording mock sender.
use std::sync::Arc;
use std::sync::Mutex;
use nomifun_api_types::{CreateWebhookRequest, UpdateWebhookRequest, UpsertTagSettingRequest, WebhookPlatform};
use nomifun_db::{
ITagSettingRepository, IWebhookRepository, SqliteTagSettingRepository, SqliteWebhookRepository,
init_database_memory,
};
use nomifun_webhook::{WebhookSender, WebhookService};
#[derive(Default)]
struct MockSender {
calls: Mutex<Vec<(String, String)>>, // (url, title)
fail: bool,
}
#[async_trait::async_trait]
impl WebhookSender for MockSender {
async fn send_card(
&self,
_platform: WebhookPlatform,
url: &str,
_secret: Option<&str>,
title: &str,
_fields: &[(String, String)],
) -> Result<(), nomifun_webhook::WebhookError> {
self.calls.lock().unwrap().push((url.to_string(), title.to_string()));
if self.fail {
return Err(nomifun_webhook::WebhookError::Remote("boom".into()));
}
Ok(())
}
}
async fn svc(sender: Arc<dyn WebhookSender>) -> WebhookService {
let db = init_database_memory().await.unwrap();
let webhooks: Arc<dyn IWebhookRepository> = Arc::new(SqliteWebhookRepository::new(db.pool().clone()));
let tags: Arc<dyn ITagSettingRepository> = Arc::new(SqliteTagSettingRepository::new(db.pool().clone()));
Box::leak(Box::new(db));
WebhookService::new(webhooks, tags, sender)
}
fn create_req() -> CreateWebhookRequest {
CreateWebhookRequest {
name: "Team bot".into(),
url: "https://open.feishu.cn/open-apis/bot/v2/hook/abc".into(),
platform: WebhookPlatform::Lark,
description: "notify".into(),
secret: Some("s3cr3t".into()),
enabled: Some(true),
}
}
#[tokio::test]
async fn create_list_update_delete_and_secret_is_hidden() {
let s = svc(Arc::new(MockSender::default())).await;
let created = s.create(create_req()).await.unwrap();
assert_eq!(created.name, "Team bot");
// secret must never be echoed; has_secret signals presence.
assert!(created.has_secret);
assert!(created.id > 0, "DB assigns a positive autoincrement id");
let list = s.list().await.unwrap();
assert_eq!(list.len(), 1);
let updated = s
.update(
created.id,
UpdateWebhookRequest {
name: Some("Renamed".into()),
enabled: Some(false),
secret: Some(None), // clear the secret
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.name, "Renamed");
assert!(!updated.enabled);
assert!(!updated.has_secret, "secret cleared via Some(None)");
s.delete(created.id).await.unwrap();
assert!(s.list().await.unwrap().is_empty());
}
#[tokio::test]
async fn create_validates_name_and_url() {
let s = svc(Arc::new(MockSender::default())).await;
let mut bad = create_req();
bad.name = " ".into();
assert!(s.create(bad).await.is_err());
let mut bad = create_req();
bad.url = "".into();
assert!(s.create(bad).await.is_err());
}
#[tokio::test]
async fn test_sends_card_and_propagates_failure() {
// success
let ok_sender = Arc::new(MockSender::default());
let s = svc(ok_sender.clone()).await;
let wh = s.create(create_req()).await.unwrap();
s.test(wh.id).await.unwrap();
assert_eq!(ok_sender.calls.lock().unwrap().len(), 1);
// failure → BadGateway
let fail_sender = Arc::new(MockSender {
fail: true,
..Default::default()
});
let s2 = svc(fail_sender).await;
let wh2 = s2.create(create_req()).await.unwrap();
let err = s2.test(wh2.id).await.unwrap_err();
assert!(matches!(err, nomifun_common::AppError::BadGateway(_)));
}
#[tokio::test]
async fn tag_setting_upsert_validates_webhook_exists() {
let s = svc(Arc::new(MockSender::default())).await;
// binding a non-existent webhook → BadRequest
let err = s
.upsert_tag_setting(
"alpha",
UpsertTagSettingRequest {
webhook_id: Some(Some(999_999)),
description: Some("x".into()),
notify_events: None,
},
)
.await
.unwrap_err();
assert!(matches!(err, nomifun_common::AppError::BadRequest(_)));
// create a webhook, then bind it
let wh = s.create(create_req()).await.unwrap();
let setting = s
.upsert_tag_setting(
"alpha",
UpsertTagSettingRequest {
webhook_id: Some(Some(wh.id)),
description: Some("queue alpha".into()),
notify_events: None,
},
)
.await
.unwrap();
assert_eq!(setting.webhook_id, Some(wh.id));
assert_eq!(setting.description, "queue alpha");
// get an unset tag → empty default shape
let empty = s.get_tag_setting("never-set").await.unwrap();
assert_eq!(empty.tag, "never-set");
assert!(empty.webhook_id.is_none());
// partial update keeps webhook binding when only description changes
let only_desc = s
.upsert_tag_setting(
"alpha",
UpsertTagSettingRequest {
webhook_id: None,
description: Some("changed".into()),
notify_events: None,
},
)
.await
.unwrap();
assert_eq!(only_desc.webhook_id, Some(wh.id));
assert_eq!(only_desc.description, "changed");
// clear the binding via Some(None)
let cleared = s
.upsert_tag_setting(
"alpha",
UpsertTagSettingRequest {
webhook_id: Some(None),
description: None,
notify_events: None,
},
)
.await
.unwrap();
assert!(cleared.webhook_id.is_none());
}