chore: remove workspace module

Delete deprecated workspace module:
- libs/api/workspace: workspace API handlers
- libs/service/workspace: workspace business logic
This commit is contained in:
zhenyi 2026-05-20 13:37:43 +08:00
parent d59647d9a8
commit 6dbbd22036
15 changed files with 0 additions and 2373 deletions

View File

@ -1,84 +0,0 @@
use crate::{ApiResponse, error::ApiError};
use actix_web::{HttpResponse, Result, web};
use service::AppService;
use service::workspace::billing::{
WorkspaceBillingAddCreditParams, WorkspaceBillingCurrentResponse, WorkspaceBillingHistoryQuery,
WorkspaceBillingHistoryResponse,
};
use session::Session;
#[utoipa::path(
get,
path = "/api/workspaces/{slug}/billing",
params(
("slug" = String, Path, description = "Workspace slug")
),
responses(
(status = 200, description = "Get workspace billing info", body = ApiResponse<WorkspaceBillingCurrentResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Not a workspace member"),
(status = 404, description = "Workspace not found"),
),
tag = "Workspace"
)]
pub async fn workspace_billing_current(
service: web::Data<AppService>,
session: Session,
path: web::Path<String>,
) -> Result<HttpResponse, ApiError> {
let slug = path.into_inner();
let resp = service.workspace_billing_current(&session, slug).await?;
Ok(ApiResponse::ok(resp).to_response())
}
#[utoipa::path(
get,
path = "/api/workspaces/{slug}/billing/history",
params(
("slug" = String, Path, description = "Workspace slug"),
),
responses(
(status = 200, description = "Get workspace billing history", body = ApiResponse<WorkspaceBillingHistoryResponse>),
(status = 401, description = "Unauthorized"),
),
tag = "Workspace"
)]
pub async fn workspace_billing_history(
service: web::Data<AppService>,
session: Session,
path: web::Path<String>,
query: web::Query<WorkspaceBillingHistoryQuery>,
) -> Result<HttpResponse, ApiError> {
let slug = path.into_inner();
let resp = service
.workspace_billing_history(&session, slug, query.into_inner())
.await?;
Ok(ApiResponse::ok(resp).to_response())
}
#[utoipa::path(
post,
path = "/api/workspaces/{slug}/billing/credits",
params(
("slug" = String, Path, description = "Workspace slug")
),
request_body = WorkspaceBillingAddCreditParams,
responses(
(status = 200, description = "Add credit to workspace billing", body = ApiResponse<WorkspaceBillingCurrentResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Not a workspace member"),
),
tag = "Workspace"
)]
pub async fn workspace_billing_add_credit(
service: web::Data<AppService>,
session: Session,
path: web::Path<String>,
body: web::Json<WorkspaceBillingAddCreditParams>,
) -> Result<HttpResponse, ApiError> {
let slug = path.into_inner();
let resp = service
.workspace_billing_add_credit(&session, slug, body.into_inner())
.await?;
Ok(ApiResponse::ok(resp).to_response())
}

View File

@ -1,42 +0,0 @@
use crate::{ApiResponse, error::ApiError};
use actix_web::{HttpResponse, Result, web};
use service::AppService;
use session::Session;
#[utoipa::path(
get,
path = "/api/workspaces/me",
responses(
(status = 200, description = "List my workspaces", body = ApiResponse<service::workspace::info::WorkspaceListResponse>),
(status = 401, description = "Unauthorized"),
),
tag = "Workspace"
)]
pub async fn workspace_list(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, ApiError> {
let resp = service.workspace_list(&session).await?;
Ok(ApiResponse::ok(resp).to_response())
}
#[utoipa::path(
get,
path = "/api/workspaces/{slug}",
params(("slug" = String, Path)),
responses(
(status = 200, description = "Get workspace info", body = ApiResponse<service::workspace::info::WorkspaceInfoResponse>),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Workspace not found"),
),
tag = "Workspace"
)]
pub async fn workspace_info(
service: web::Data<AppService>,
session: Session,
path: web::Path<String>,
) -> Result<HttpResponse, ApiError> {
let slug = path.into_inner();
let resp = service.workspace_info(&session, slug).await?;
Ok(ApiResponse::ok(resp).to_response())
}

View File

@ -1,26 +0,0 @@
use crate::{ApiResponse, error::ApiError};
use actix_web::{HttpResponse, Result, web};
use service::AppService;
use service::workspace::init::WorkspaceInitParams;
use session::Session;
#[utoipa::path(
post,
path = "/api/workspaces",
request_body = WorkspaceInitParams,
responses(
(status = 200, description = "Create workspace", body = ApiResponse<service::workspace::info::WorkspaceInfoResponse>),
(status = 401, description = "Unauthorized"),
(status = 409, description = "Slug or name already exists"),
),
tag = "Workspace"
)]
pub async fn workspace_create(
service: web::Data<AppService>,
session: Session,
body: web::Json<WorkspaceInitParams>,
) -> Result<HttpResponse, ApiError> {
let ws = service.workspace_init(&session, body.into_inner()).await?;
let resp = service.workspace_info(&session, ws.slug.clone()).await?;
Ok(ApiResponse::ok(resp).to_response())
}

View File

@ -1,249 +0,0 @@
use crate::{ApiResponse, error::ApiError};
use actix_web::{HttpResponse, Result, web};
use service::AppService;
use service::workspace::members::{
MyWorkspaceInvitation, PendingInvitationInfo, WorkspaceAcceptBySlugParams,
WorkspaceInviteAcceptParams, WorkspaceInviteParams, WorkspaceMembersResponse,
};
use session::Session;
use uuid::Uuid;
#[derive(serde::Deserialize, utoipa::IntoParams)]
pub struct MembersQuery {
pub page: Option<u64>,
pub per_page: Option<u64>,
}
#[utoipa::path(
get,
path = "/api/workspaces/{slug}/members",
params(
("slug" = String, Path),
("page" = Option<u64>, Query),
("per_page" = Option<u64>, Query),
),
responses(
(status = 200, description = "List workspace members", body = ApiResponse<WorkspaceMembersResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Not a member"),
(status = 404, description = "Workspace not found"),
),
tag = "Workspace"
)]
pub async fn workspace_members(
service: web::Data<AppService>,
session: Session,
path: web::Path<String>,
query: web::Query<MembersQuery>,
) -> Result<HttpResponse, ApiError> {
let slug = path.into_inner();
let resp = service
.workspace_members(&session, slug, query.page, query.per_page)
.await?;
Ok(ApiResponse::ok(resp).to_response())
}
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub struct UpdateRoleParams {
pub user_id: Uuid,
pub role: String,
}
#[utoipa::path(
patch,
path = "/api/workspaces/{slug}/members/role",
params(("slug" = String, Path)),
request_body = UpdateRoleParams,
responses(
(status = 200, description = "Update member role"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Permission denied"),
(status = 404, description = "Workspace or member not found"),
),
tag = "Workspace"
)]
pub async fn workspace_update_member_role(
service: web::Data<AppService>,
session: Session,
path: web::Path<String>,
body: web::Json<UpdateRoleParams>,
) -> Result<HttpResponse, ApiError> {
let slug = path.into_inner();
service
.workspace_update_member_role(&session, slug, body.user_id, body.role.clone())
.await?;
Ok(ApiResponse::ok(serde_json::json!({ "success": true })).to_response())
}
#[utoipa::path(
delete,
path = "/api/workspaces/{slug}/members/{user_id}",
params(
("slug" = String, Path),
("user_id" = Uuid, Path),
),
responses(
(status = 200, description = "Remove member"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Permission denied"),
(status = 404, description = "Member not found"),
),
tag = "Workspace"
)]
pub async fn workspace_remove_member(
service: web::Data<AppService>,
session: Session,
path: web::Path<(String, Uuid)>,
) -> Result<HttpResponse, ApiError> {
let (slug, user_id) = path.into_inner();
service
.workspace_remove_member(&session, slug, user_id)
.await?;
Ok(ApiResponse::ok(serde_json::json!({ "success": true })).to_response())
}
#[utoipa::path(
get,
path = "/api/workspaces/{slug}/invitations",
params(("slug" = String, Path)),
responses(
(status = 200, description = "List pending invitations", body = ApiResponse<Vec<PendingInvitationInfo>>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Permission denied"),
(status = 404, description = "Workspace not found"),
),
tag = "Workspace"
)]
pub async fn workspace_pending_invitations(
service: web::Data<AppService>,
session: Session,
path: web::Path<String>,
) -> Result<HttpResponse, ApiError> {
let slug = path.into_inner();
let resp = service
.workspace_pending_invitations(&session, slug)
.await?;
Ok(ApiResponse::ok(resp).to_response())
}
#[utoipa::path(
delete,
path = "/api/workspaces/{slug}/invitations/{user_id}",
params(
("slug" = String, Path),
("user_id" = Uuid, Path),
),
responses(
(status = 200, description = "Cancel invitation"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Permission denied"),
(status = 404, description = "Invitation not found"),
),
tag = "Workspace"
)]
pub async fn workspace_cancel_invitation(
service: web::Data<AppService>,
session: Session,
path: web::Path<(String, Uuid)>,
) -> Result<HttpResponse, ApiError> {
let (slug, user_id) = path.into_inner();
service
.workspace_cancel_invitation(&session, slug, user_id)
.await?;
Ok(ApiResponse::ok(serde_json::json!({ "success": true })).to_response())
}
#[utoipa::path(
post,
path = "/api/workspaces/{slug}/invitations",
params(("slug" = String, Path)),
request_body = WorkspaceInviteParams,
responses(
(status = 200, description = "Send invitation"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Permission denied"),
(status = 404, description = "User not found"),
(status = 409, description = "Already a member"),
),
tag = "Workspace"
)]
pub async fn workspace_invite_member(
service: web::Data<AppService>,
session: Session,
path: web::Path<String>,
body: web::Json<WorkspaceInviteParams>,
) -> Result<HttpResponse, ApiError> {
let slug = path.into_inner();
service
.workspace_invite_member(&session, slug, body.into_inner())
.await?;
Ok(ApiResponse::ok(serde_json::json!({ "success": true })).to_response())
}
#[utoipa::path(
post,
path = "/api/workspaces/invitations/accept",
request_body = WorkspaceInviteAcceptParams,
responses(
(status = 200, description = "Accept invitation", body = ApiResponse<service::workspace::info::WorkspaceInfoResponse>),
(status = 400, description = "Invalid or expired token"),
(status = 401, description = "Unauthorized"),
(status = 409, description = "Already accepted"),
),
tag = "Workspace"
)]
pub async fn workspace_accept_invitation(
service: web::Data<AppService>,
session: Session,
body: web::Json<WorkspaceInviteAcceptParams>,
) -> Result<HttpResponse, ApiError> {
let ws = service
.workspace_accept_invitation(&session, body.into_inner())
.await?;
let resp = service.workspace_info(&session, ws.slug).await?;
Ok(ApiResponse::ok(resp).to_response())
}
/// List all pending workspace invitations for the current user.
#[utoipa::path(
get,
path = "/api/workspaces/me/invitations",
responses(
(status = 200, description = "List my workspace invitations", body = ApiResponse<Vec<MyWorkspaceInvitation>>),
(status = 401, description = "Unauthorized"),
),
tag = "Workspace"
)]
pub async fn workspace_my_invitations(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, ApiError> {
let resp = service.workspace_my_pending_invitations(&session).await?;
Ok(ApiResponse::ok(resp).to_response())
}
/// Accept a workspace invitation by slug.
#[utoipa::path(
post,
path = "/api/workspaces/invitations/accept-by-slug",
request_body = WorkspaceAcceptBySlugParams,
responses(
(status = 200, description = "Accept invitation", body = ApiResponse<service::workspace::info::WorkspaceInfoResponse>),
(status = 400, description = "Invalid or expired token"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Invitation not found"),
(status = 409, description = "Already accepted"),
),
tag = "Workspace"
)]
pub async fn workspace_accept_invitation_by_slug(
service: web::Data<AppService>,
session: Session,
body: web::Json<WorkspaceAcceptBySlugParams>,
) -> Result<HttpResponse, ApiError> {
let ws = service
.workspace_accept_invitation_by_slug(&session, body.into_inner())
.await?;
let resp = service.workspace_info(&session, ws.slug).await?;
Ok(ApiResponse::ok(resp).to_response())
}

View File

@ -1,76 +0,0 @@
pub mod billing;
pub mod info;
pub mod init;
pub mod members;
pub mod projects;
pub mod settings;
pub mod stats;
use actix_web::web;
pub fn init_workspace_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/workspaces")
.route("", web::post().to(init::workspace_create))
.route("/me", web::get().to(info::workspace_list))
.route("/{slug}", web::get().to(info::workspace_info))
.route(
"/{slug}/billing",
web::get().to(billing::workspace_billing_current),
)
.route(
"/{slug}/billing/history",
web::get().to(billing::workspace_billing_history),
)
.route(
"/{slug}/billing/credits",
web::post().to(billing::workspace_billing_add_credit),
)
.route(
"/{slug}/projects",
web::get().to(projects::workspace_projects),
)
.route("/{slug}/stats", web::get().to(stats::workspace_stats))
.route("/{slug}", web::patch().to(settings::workspace_update))
.route("/{slug}", web::delete().to(settings::workspace_delete))
.route("/{slug}/members", web::get().to(members::workspace_members))
.route(
"/{slug}/members/{user_id}",
web::delete().to(members::workspace_remove_member),
)
.route(
"/{slug}/members/role",
web::patch().to(members::workspace_update_member_role),
)
.route(
"/me/invitations",
web::get().to(members::workspace_my_invitations),
)
.route(
"/invitations/accept",
web::post().to(members::workspace_accept_invitation),
)
.route(
"/invitations/accept-by-slug",
web::post().to(members::workspace_accept_invitation_by_slug),
)
.route(
"/{slug}/invitations",
web::post().to(members::workspace_invite_member),
)
.route(
"/{slug}/invitations",
web::get().to(members::workspace_pending_invitations),
)
.route(
"/{slug}/invitations/{user_id}",
web::delete().to(members::workspace_cancel_invitation),
),
);
}

View File

@ -1,32 +0,0 @@
use crate::{ApiResponse, error::ApiError};
use actix_web::{HttpResponse, Result, web};
use service::AppService;
use service::workspace::info::{WorkspaceProjectsQuery, WorkspaceProjectsResponse};
use session::Session;
#[utoipa::path(
get,
path = "/api/workspaces/{slug}/projects",
params(
("slug" = String, Path, description = "Workspace slug"),
),
responses(
(status = 200, description = "List workspace projects", body = ApiResponse<WorkspaceProjectsResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Not a workspace member"),
(status = 404, description = "Workspace not found"),
),
tag = "Workspace"
)]
pub async fn workspace_projects(
service: web::Data<AppService>,
session: Session,
path: web::Path<String>,
query: web::Query<WorkspaceProjectsQuery>,
) -> Result<HttpResponse, ApiError> {
let slug = path.into_inner();
let resp = service
.workspace_projects(&session, slug, query.into_inner())
.await?;
Ok(ApiResponse::ok(resp).to_response())
}

View File

@ -1,55 +0,0 @@
use crate::{ApiResponse, error::ApiError};
use actix_web::{HttpResponse, Result, web};
use service::AppService;
use service::workspace::settings::WorkspaceUpdateParams;
use session::Session;
#[utoipa::path(
patch,
path = "/api/workspaces/{slug}",
params(("slug" = String, Path)),
request_body = WorkspaceUpdateParams,
responses(
(status = 200, description = "Update workspace", body = ApiResponse<service::workspace::info::WorkspaceInfoResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Permission denied"),
(status = 404, description = "Workspace not found"),
(status = 409, description = "Name already exists"),
),
tag = "Workspace"
)]
pub async fn workspace_update(
service: web::Data<AppService>,
session: Session,
path: web::Path<String>,
body: web::Json<WorkspaceUpdateParams>,
) -> Result<HttpResponse, ApiError> {
let slug = path.into_inner();
let ws = service
.workspace_update(&session, slug, body.into_inner())
.await?;
let resp = service.workspace_info(&session, ws.slug).await?;
Ok(ApiResponse::ok(resp).to_response())
}
#[utoipa::path(
delete,
path = "/api/workspaces/{slug}",
params(("slug" = String, Path)),
responses(
(status = 200, description = "Delete workspace"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Permission denied (owner only)"),
(status = 404, description = "Workspace not found"),
),
tag = "Workspace"
)]
pub async fn workspace_delete(
service: web::Data<AppService>,
session: Session,
path: web::Path<String>,
) -> Result<HttpResponse, ApiError> {
let slug = path.into_inner();
service.workspace_delete(&session, slug).await?;
Ok(ApiResponse::ok(serde_json::json!({ "success": true })).to_response())
}

View File

@ -1,29 +0,0 @@
use crate::{ApiResponse, error::ApiError};
use actix_web::{HttpResponse, Result, web};
use service::AppService;
use service::workspace::info::WorkspaceStatsResponse;
use session::Session;
#[utoipa::path(
get,
path = "/api/workspaces/{slug}/stats",
params(
("slug" = String, Path, description = "Workspace slug")
),
responses(
(status = 200, description = "Get workspace stats", body = ApiResponse<WorkspaceStatsResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Not a workspace member"),
(status = 404, description = "Workspace not found"),
),
tag = "Workspace"
)]
pub async fn workspace_stats(
service: web::Data<AppService>,
session: Session,
path: web::Path<String>,
) -> Result<HttpResponse, ApiError> {
let slug = path.into_inner();
let resp = service.workspace_stats(&session, slug).await?;
Ok(ApiResponse::ok(resp).to_response())
}

View File

@ -1,330 +0,0 @@
//! Workspace billing alert checker.
//!
//! Periodically checks all workspaces against their alert config and enqueues
//! email notifications when thresholds are exceeded.
//!
//! Alert types:
//! - `low_balance` — balance falls below threshold
//! - `monthly_quota` — month_used exceeds monthly_quota * threshold (e.g. 0.8 = 80%)
use crate::AppService;
use chrono::{Datelike, Utc};
use models::workspaces::{
workspace, workspace_alert_config, workspace_billing, workspace_billing_history,
workspace_membership,
};
use queue::EmailEnvelope;
use rust_decimal::prelude::ToPrimitive;
use sea_orm::*;
use serde::{Deserialize, Serialize};
use tokio::time::{interval, Duration};
use uuid::Uuid;
// ─── Types ─────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CheckAlertsResponse {
pub workspaces_checked: usize,
pub alerts_sent: usize,
pub details: Vec<AlertDetail>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct AlertDetail {
pub workspace_id: Uuid,
pub workspace_name: String,
pub alert_type: String,
pub threshold: f64,
pub current_value: f64,
pub recipients: Vec<String>,
}
// ─── AppService impl ────────────────────────────────────────────────────────
impl AppService {
/// Run billing alert checks for all workspaces.
/// Called periodically by the background task and optionally via admin API.
pub async fn check_billing_alerts(&self) -> CheckAlertsResponse {
let mut details = Vec::new();
let mut alerts_sent = 0;
let workspaces = workspace::Entity::find()
.filter(workspace::Column::DeletedAt.is_null())
.all(&self.db)
.await
.unwrap_or_default();
let count = workspaces.len();
for ws in workspaces {
let ws_details = self.check_workspace_alerts(&ws).await;
alerts_sent += ws_details.alerts_sent;
details.extend(ws_details.details);
}
CheckAlertsResponse { workspaces_checked: count, alerts_sent, details }
}
async fn check_workspace_alerts(
&self,
ws: &workspace::Model,
) -> CheckAlertsResponse {
let mut details = Vec::new();
let mut alerts_sent = 0usize;
let Some(billing) = workspace_billing::Entity::find_by_id(ws.id)
.one(&self.db)
.await
.ok()
.flatten()
else {
return CheckAlertsResponse { workspaces_checked: 1, alerts_sent: 0, details };
};
let configs = workspace_alert_config::Entity::find()
.filter(workspace_alert_config::Column::WorkspaceId.eq(ws.id))
.filter(workspace_alert_config::Column::Enabled.eq(true))
.all(&self.db)
.await
.unwrap_or_default();
if configs.is_empty() {
return CheckAlertsResponse { workspaces_checked: 1, alerts_sent: 0, details };
}
let month_used = self.current_month_usage(ws.id).await;
let recipients = self.alert_recipients(ws.id).await;
if recipients.is_empty() {
return CheckAlertsResponse { workspaces_checked: 1, alerts_sent: 0, details };
}
let threshold_f64 = |d: &rust_decimal::Decimal| -> f64 {
use rust_decimal::prelude::ToPrimitive;
d.to_f64().unwrap_or_default()
};
for config in configs {
if !config.email_enabled {
continue;
}
let triggered = match config.alert_type.as_str() {
"low_balance" => threshold_f64(&billing.balance) < threshold_f64(&config.threshold),
"monthly_quota" => {
let quota = threshold_f64(&billing.monthly_quota);
quota > 0.0 && month_used > quota * threshold_f64(&config.threshold)
}
"usage_surge" => {
let quota = threshold_f64(&billing.monthly_quota);
quota > 0.0 && month_used > quota * 0.5
}
_ => false,
};
if triggered {
let (subject, body) = self.build_alert_email(ws, &billing, month_used, &config);
for recipient in &recipients {
let envelope = EmailEnvelope {
id: Uuid::now_v7(),
to: recipient.clone(),
subject: subject.clone(),
body: body.clone(),
created_at: Utc::now(),
};
if self.queue_producer.publish_email(envelope).await.is_ok() {
alerts_sent += 1;
}
}
details.push(AlertDetail {
workspace_id: ws.id,
workspace_name: ws.name.clone(),
alert_type: config.alert_type.clone(),
threshold: threshold_f64(&config.threshold),
current_value: if config.alert_type == "low_balance" {
threshold_f64(&billing.balance)
} else {
month_used
},
recipients: recipients.clone(),
});
}
}
CheckAlertsResponse { workspaces_checked: 1, alerts_sent, details }
}
fn build_alert_email(
&self,
ws: &workspace::Model,
billing: &workspace_billing::Model,
month_used: f64,
config: &workspace_alert_config::Model,
) -> (String, String) {
let currency = &billing.currency;
let threshold = config.threshold.to_f64().unwrap_or_default();
match config.alert_type.as_str() {
"low_balance" => {
let balance = billing.balance.to_f64().unwrap_or_default();
(
format!("[告警] Workspace「{}」余额不足", ws.name),
format!(
"您好,\n\n\
Workspace{}\n\n\
: {:.4} {}\n\
: {:.4} {}\n\n\
\n\n\
",
ws.name, balance, currency, threshold, currency
),
)
}
"monthly_quota" => {
let quota = billing.monthly_quota.to_f64().unwrap_or_default();
(
format!("[告警] Workspace「{}」月度配额即将用尽", ws.name),
format!(
"您好,\n\n\
Workspace{}\n\n\
使: {:.4} {}\n\
: {:.4} {}\n\
: {:.0}%\n\n\
使\n\n\
",
ws.name, month_used, currency, quota, currency,
threshold * 100.0
),
)
}
"usage_surge" => {
let quota = billing.monthly_quota.to_f64().unwrap_or_default();
(
format!("[告警] Workspace「{}」使用量激增", ws.name),
format!(
"您好,\n\n\
Workspace{}使\n\n\
使: {:.4} {}: {:.4} {}\n\n\
",
ws.name, month_used, currency, quota, currency
),
)
}
_ => (
format!("[告警] Workspace「{}", ws.name),
format!(
"您好,\n\nWorkspace「{}」触发告警: {}\n\n此邮件由系统自动发送。",
ws.name, config.alert_type
),
),
}
}
async fn current_month_usage(&self, workspace_id: Uuid) -> f64 {
let now = Utc::now();
let year = now.year();
let month = now.month();
let month_start = chrono::NaiveDate::from_ymd_opt(year, month, 1)
.and_then(|d| d.and_hms_opt(0, 0, 0))
.map(|d| chrono::TimeZone::from_utc_datetime(&chrono::Utc, &d))
.unwrap_or(now);
let next_month_date = if month == 12 {
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
} else {
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1)
}
.and_then(|d| d.and_hms_opt(0, 0, 0))
.map(|d| chrono::TimeZone::from_utc_datetime(&chrono::Utc, &d))
.unwrap_or(now);
let month_used: f64 = workspace_billing_history::Entity::find()
.filter(workspace_billing_history::Column::WorkspaceId.eq(workspace_id))
.filter(workspace_billing_history::Column::Reason.like("ai_usage%"))
.filter(workspace_billing_history::Column::CreatedAt.gte(month_start))
.filter(workspace_billing_history::Column::CreatedAt.lt(next_month_date))
.all(&self.db)
.await
.ok()
.unwrap_or_default()
.into_iter()
.map(|r| r.amount.to_f64().unwrap_or_default())
.sum();
-month_used
}
/// Get email addresses for workspace owners and admins who have email notifications enabled.
async fn alert_recipients(&self, workspace_id: Uuid) -> Vec<String> {
let members = workspace_membership::Entity::find()
.filter(workspace_membership::Column::WorkspaceId.eq(workspace_id))
.filter(
workspace_membership::Column::Role
.is_in(["owner", "admin"]),
)
.filter(workspace_membership::Column::Status.eq("active"))
.all(&self.db)
.await
.ok()
.unwrap_or_default();
let member_ids: Vec<Uuid> = members.iter().map(|m| m.user_id).collect();
let notifications: std::collections::HashMap<Uuid, bool> = if !member_ids.is_empty() {
models::users::user_notification::Entity::find()
.filter(models::users::user_notification::Column::User.is_in(member_ids.clone()))
.all(&self.db)
.await
.unwrap_or_default()
.into_iter()
.map(|n| (n.user, n.email_enabled))
.collect()
} else {
std::collections::HashMap::new()
};
let emails_map: std::collections::HashMap<Uuid, String> = if !member_ids.is_empty() {
models::users::user_email::Entity::find()
.filter(models::users::user_email::Column::User.is_in(member_ids.clone()))
.all(&self.db)
.await
.unwrap_or_default()
.into_iter()
.filter_map(|e| Some((e.user, e.email)))
.collect()
} else {
std::collections::HashMap::new()
};
let mut emails = Vec::new();
for member in members {
let notif_enabled = notifications.get(&member.user_id).copied().unwrap_or(true);
if !notif_enabled {
continue;
}
if let Some(email) = emails_map.get(&member.user_id) {
emails.push(email.clone());
}
}
emails
}
/// Spawn the background billing alert checker task.
/// Runs every ALERT_CHECK_INTERVAL seconds.
pub fn start_billing_alert_task(self) -> tokio::task::JoinHandle<()> {
const ALERT_CHECK_INTERVAL: u64 = 30 * 60; // 30 minutes
tokio::spawn(async move {
let mut tick = interval(Duration::from_secs(ALERT_CHECK_INTERVAL));
loop {
tick.tick().await;
let result = self.check_billing_alerts().await;
if result.alerts_sent > 0 {
tracing::info!(
workspaces_checked = result.workspaces_checked,
alerts_sent = result.alerts_sent,
"billing_alerts_sent"
);
}
}
})
}
}

View File

@ -1,280 +0,0 @@
use crate::AppService;
use crate::error::AppError;
use chrono::{DateTime, Datelike, NaiveDate, Utc};
use models::Decimal;
use models::workspaces::{workspace_billing, workspace_billing_history, workspace_membership};
use sea_orm::sea_query::prelude::rust_decimal::prelude::ToPrimitive;
use sea_orm::*;
use serde::{Deserialize, Serialize};
use session::Session;
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
/// Default monthly AI quota for workspace (shared across all its projects).
const DEFAULT_MONTHLY_QUOTA: f64 = 100.0;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct WorkspaceBillingCurrentResponse {
pub workspace_id: Uuid,
pub currency: String,
pub monthly_quota: f64,
pub balance: f64,
pub total_spent: f64,
pub month_used: f64,
pub cycle_start_utc: DateTime<Utc>,
pub cycle_end_utc: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoParams)]
pub struct WorkspaceBillingHistoryQuery {
pub page: Option<u64>,
pub per_page: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct WorkspaceBillingHistoryItem {
pub uid: Uuid,
pub workspace_id: Uuid,
pub user_id: Option<Uuid>,
pub amount: f64,
pub currency: String,
pub reason: String,
pub extra: Option<serde_json::Value>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct WorkspaceBillingHistoryResponse {
pub page: u64,
pub per_page: u64,
pub total: u64,
pub list: Vec<WorkspaceBillingHistoryItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct WorkspaceBillingAddCreditParams {
pub amount: f64,
pub reason: Option<String>,
}
impl AppService {
/// Get current workspace billing info.
pub async fn workspace_billing_current(
&self,
ctx: &Session,
workspace_slug: String,
) -> Result<WorkspaceBillingCurrentResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self
.utils_find_workspace_by_slug(workspace_slug.clone())
.await?;
let _ = workspace_membership::Entity::find()
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
.filter(workspace_membership::Column::UserId.eq(user_uid))
.filter(workspace_membership::Column::Status.eq("active"))
.one(&self.db)
.await?
.ok_or(AppError::NotWorkspaceMember)?;
let billing = self.ensure_workspace_billing(ws.id, Some(user_uid)).await?;
let now_utc = Utc::now();
let (month_start, next_month_start) = utc_month_bounds(now_utc)?;
let month_used = workspace_billing_history::Entity::find()
.filter(workspace_billing_history::Column::WorkspaceId.eq(ws.id))
.filter(workspace_billing_history::Column::Reason.like("ai_usage%"))
.filter(workspace_billing_history::Column::CreatedAt.gte(month_start))
.filter(workspace_billing_history::Column::CreatedAt.lt(next_month_start))
.all(&self.db)
.await?
.into_iter()
.map(|m| m.amount.to_f64().unwrap_or_default())
.sum::<f64>();
let month_used = -month_used;
Ok(WorkspaceBillingCurrentResponse {
workspace_id: ws.id,
currency: billing.currency.clone(),
monthly_quota: billing
.monthly_quota
.to_f64()
.unwrap_or(DEFAULT_MONTHLY_QUOTA),
balance: billing.balance.to_f64().unwrap_or_default(),
total_spent: billing.total_spent.to_f64().unwrap_or_default(),
month_used,
cycle_start_utc: month_start,
cycle_end_utc: next_month_start,
updated_at: billing.updated_at,
created_at: billing.created_at,
})
}
/// Get workspace billing history.
pub async fn workspace_billing_history(
&self,
ctx: &Session,
workspace_slug: String,
query: WorkspaceBillingHistoryQuery,
) -> Result<WorkspaceBillingHistoryResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self
.utils_find_workspace_by_slug(workspace_slug.clone())
.await?;
let _ = workspace_membership::Entity::find()
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
.filter(workspace_membership::Column::UserId.eq(user_uid))
.filter(workspace_membership::Column::Status.eq("active"))
.one(&self.db)
.await?
.ok_or(AppError::NotWorkspaceMember)?;
let page = std::cmp::max(query.page.unwrap_or(1), 1);
let per_page = query.per_page.unwrap_or(20).clamp(1, 200);
self.ensure_workspace_billing(ws.id, Some(user_uid)).await?;
let paginator = workspace_billing_history::Entity::find()
.filter(workspace_billing_history::Column::WorkspaceId.eq(ws.id))
.order_by_desc(workspace_billing_history::Column::CreatedAt)
.paginate(&self.db, per_page);
let total = paginator.num_items().await?;
let rows = paginator.fetch_page(page - 1).await?;
let list = rows
.into_iter()
.map(|x| WorkspaceBillingHistoryItem {
uid: x.uid,
workspace_id: x.workspace_id,
user_id: x.user_id,
amount: x.amount.to_f64().unwrap_or_default(),
currency: x.currency,
reason: x.reason,
extra: x.extra.map(|v| v.into()),
created_at: x.created_at,
})
.collect();
Ok(WorkspaceBillingHistoryResponse {
page,
per_page,
total,
list,
})
}
/// Add credit to workspace billing (admin action).
pub async fn workspace_billing_add_credit(
&self,
ctx: &Session,
workspace_slug: String,
params: WorkspaceBillingAddCreditParams,
) -> Result<WorkspaceBillingCurrentResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self
.utils_find_workspace_by_slug(workspace_slug.clone())
.await?;
let _ = workspace_membership::Entity::find()
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
.filter(workspace_membership::Column::UserId.eq(user_uid))
.filter(workspace_membership::Column::Status.eq("active"))
.one(&self.db)
.await?
.ok_or(AppError::NotWorkspaceMember)?;
if params.amount <= 0.0 {
return Err(AppError::BadRequest("Amount must be positive".to_string()));
}
let billing = self.ensure_workspace_billing(ws.id, Some(user_uid)).await?;
let now_utc = Utc::now();
let amount_dec =
Decimal::from_f64_retain(params.amount).unwrap_or(Decimal::ZERO);
let new_balance = billing.balance + amount_dec;
let currency = billing.currency.clone();
let mut updated: workspace_billing::ActiveModel = billing.into();
updated.balance = Set(new_balance);
updated.updated_at = Set(now_utc);
updated.update(&self.db).await?;
let _ = workspace_billing_history::ActiveModel {
uid: Set(Uuid::now_v7()),
workspace_id: Set(ws.id),
user_id: Set(Some(user_uid)),
amount: Set(Decimal::from_f64_retain(params.amount).unwrap_or(Decimal::ZERO)),
currency: Set(currency),
reason: Set(params.reason.unwrap_or_else(|| "credit_added".to_string())),
extra: Set(None),
created_at: Set(now_utc),
}
.insert(&self.db)
.await;
self.workspace_billing_current(ctx, workspace_slug).await
}
/// Ensure workspace billing record exists (create with defaults if not).
pub async fn ensure_workspace_billing(
&self,
workspace_id: Uuid,
user_uid: Option<Uuid>,
) -> Result<workspace_billing::Model, AppError> {
if let Some(billing) = workspace_billing::Entity::find_by_id(workspace_id)
.one(&self.db)
.await?
{
return Ok(billing);
}
let now_utc = Utc::now();
// Only first workspace per user gets initial budget ($30)
let initial_balance = if let Some(uid) = user_uid {
let existing_workspaces = workspace_membership::Entity::find()
.filter(workspace_membership::Column::UserId.eq(uid))
.filter(workspace_membership::Column::Status.eq("active"))
.all(&self.db)
.await?;
if existing_workspaces.len() <= 1 {
Decimal::from_f64_retain(30.0).unwrap_or(Decimal::ZERO)
} else {
Decimal::ZERO
}
} else {
Decimal::ZERO
};
let created = workspace_billing::ActiveModel {
workspace_id: Set(workspace_id),
balance: Set(initial_balance),
currency: Set("USD".to_string()),
monthly_quota: Set(
Decimal::from_f64_retain(DEFAULT_MONTHLY_QUOTA).unwrap_or(Decimal::ZERO)
),
total_spent: Set(Decimal::ZERO),
updated_at: Set(now_utc),
created_at: Set(now_utc),
};
Ok(created.insert(&self.db).await?)
}
}
fn utc_month_bounds(now_utc: DateTime<Utc>) -> Result<(DateTime<Utc>, DateTime<Utc>), AppError> {
let year = now_utc.year();
let month = now_utc.month();
let month_start = NaiveDate::from_ymd_opt(year, month, 1)
.and_then(|d| d.and_hms_opt(0, 0, 0))
.map(|d| chrono::TimeZone::from_utc_datetime(&Utc, &d))
.ok_or_else(|| AppError::InternalServerError("Invalid UTC month start".to_string()))?;
let (next_year, next_month) = if month == 12 {
(year + 1, 1)
} else {
(year, month + 1)
};
let next_month_start = NaiveDate::from_ymd_opt(next_year, next_month, 1)
.and_then(|d| d.and_hms_opt(0, 0, 0))
.map(|d| chrono::TimeZone::from_utc_datetime(&Utc, &d))
.ok_or_else(|| AppError::InternalServerError("Invalid UTC next month start".to_string()))?;
Ok((month_start, next_month_start))
}

View File

@ -1,328 +0,0 @@
use crate::AppService;
use crate::error::AppError;
use chrono::{DateTime, Utc};
use models::projects::project;
use models::projects::project_activity;
use models::users::user;
use models::workspaces::workspace;
use models::workspaces::workspace_membership;
use sea_orm::*;
use serde::{Deserialize, Serialize};
use session::Session;
use utoipa::IntoParams;
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WorkspaceInfoResponse {
pub id: Uuid,
pub slug: String,
pub name: String,
pub description: Option<String>,
pub avatar_url: Option<String>,
pub plan: String,
pub billing_email: Option<String>,
pub member_count: i64,
pub my_role: Option<String>,
pub created_at: chrono::DateTime<Utc>,
pub updated_at: chrono::DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WorkspaceListItem {
pub id: Uuid,
pub slug: String,
pub name: String,
pub description: Option<String>,
pub avatar_url: Option<String>,
pub plan: String,
pub my_role: String,
pub created_at: chrono::DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WorkspaceListResponse {
pub workspaces: Vec<WorkspaceListItem>,
pub total: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, IntoParams)]
pub struct WorkspaceProjectsQuery {
pub page: Option<u64>,
pub per_page: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WorkspaceProjectItem {
pub uid: Uuid,
pub name: String,
pub display_name: String,
pub avatar_url: Option<String>,
pub description: Option<String>,
pub is_public: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WorkspaceProjectsResponse {
pub projects: Vec<WorkspaceProjectItem>,
pub total: u64,
pub page: u64,
pub per_page: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WorkspaceActivityItem {
pub id: i64,
pub project_name: String,
pub event_type: String,
pub title: String,
pub content: Option<String>,
pub actor_name: String,
pub actor_avatar: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WorkspaceStatsResponse {
pub project_count: i64,
pub member_count: i64,
pub my_role: Option<String>,
pub recent_activities: Vec<WorkspaceActivityItem>,
}
impl AppService {
/// Get workspace info by slug. Returns error if user is not a member.
pub async fn workspace_info(
&self,
ctx: &Session,
slug: String,
) -> Result<WorkspaceInfoResponse, AppError> {
let user_uid = ctx.user();
let ws = self.utils_find_workspace_by_slug(slug.clone()).await?;
let my_role = if let Some(uid) = user_uid {
workspace_membership::Entity::find()
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
.filter(workspace_membership::Column::UserId.eq(uid))
.filter(workspace_membership::Column::Status.eq("active"))
.one(&self.db)
.await?
.map(|m| m.role)
} else {
None
};
let member_count = workspace_membership::Entity::find()
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
.filter(workspace_membership::Column::Status.eq("active"))
.count(&self.db)
.await?;
Ok(WorkspaceInfoResponse {
id: ws.id,
slug: ws.slug,
name: ws.name,
description: ws.description,
avatar_url: ws.avatar_url,
plan: ws.plan,
billing_email: ws.billing_email,
member_count: member_count as i64,
my_role,
created_at: ws.created_at,
updated_at: ws.updated_at,
})
}
/// List all workspaces the current user is a member of.
pub async fn workspace_list(&self, ctx: &Session) -> Result<WorkspaceListResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let memberships = workspace_membership::Entity::find()
.filter(workspace_membership::Column::UserId.eq(user_uid))
.filter(workspace_membership::Column::Status.eq("active"))
.all(&self.db)
.await?;
let workspace_ids: Vec<Uuid> = memberships.iter().map(|m| m.workspace_id).collect();
let total = workspace_ids.len() as u64;
let workspaces = workspace::Entity::find()
.filter(workspace::Column::Id.is_in(workspace_ids.clone()))
.filter(workspace::Column::DeletedAt.is_null())
.all(&self.db)
.await?;
let items: Vec<WorkspaceListItem> = workspaces
.into_iter()
.filter_map(|ws| {
let membership = memberships
.iter()
.find(|m| m.workspace_id == ws.id)
.cloned()?;
Some(WorkspaceListItem {
id: ws.id,
slug: ws.slug,
name: ws.name,
description: ws.description,
avatar_url: ws.avatar_url,
plan: ws.plan,
my_role: membership.role,
created_at: ws.created_at,
})
})
.collect();
Ok(WorkspaceListResponse {
workspaces: items,
total,
})
}
/// List projects belonging to a workspace.
pub async fn workspace_projects(
&self,
ctx: &Session,
workspace_slug: String,
query: WorkspaceProjectsQuery,
) -> Result<WorkspaceProjectsResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.utils_find_workspace_by_slug(workspace_slug).await?;
let _ = workspace_membership::Entity::find()
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
.filter(workspace_membership::Column::UserId.eq(user_uid))
.filter(workspace_membership::Column::Status.eq("active"))
.one(&self.db)
.await?
.ok_or(AppError::NotWorkspaceMember)?;
let page = std::cmp::max(query.page.unwrap_or(1), 1);
let per_page = query.per_page.unwrap_or(20).clamp(1, 200);
let paginator = project::Entity::find()
.filter(project::Column::WorkspaceId.eq(ws.id))
.order_by_desc(project::Column::CreatedAt)
.paginate(&self.db, per_page);
let total = paginator.num_items().await?;
let rows = paginator.fetch_page(page - 1).await?;
let projects = rows
.into_iter()
.map(|p| WorkspaceProjectItem {
uid: p.id,
name: p.name,
display_name: p.display_name,
avatar_url: p.avatar_url,
description: p.description,
is_public: p.is_public,
created_at: p.created_at,
updated_at: p.updated_at,
})
.collect();
Ok(WorkspaceProjectsResponse {
projects,
total,
page,
per_page,
})
}
/// Get workspace stats: project count, member count, recent activities.
pub async fn workspace_stats(
&self,
ctx: &Session,
workspace_slug: String,
) -> Result<WorkspaceStatsResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.utils_find_workspace_by_slug(workspace_slug).await?;
let membership = workspace_membership::Entity::find()
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
.filter(workspace_membership::Column::UserId.eq(user_uid))
.filter(workspace_membership::Column::Status.eq("active"))
.one(&self.db)
.await?;
let member_count = workspace_membership::Entity::find()
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
.filter(workspace_membership::Column::Status.eq("active"))
.count(&self.db)
.await?;
let project_count = project::Entity::find()
.filter(project::Column::WorkspaceId.eq(ws.id))
.count(&self.db)
.await?;
// Get recent activities across all workspace projects
let workspace_projects = project::Entity::find()
.filter(project::Column::WorkspaceId.eq(ws.id))
.all(&self.db)
.await?;
let project_ids: Vec<Uuid> = workspace_projects.iter().map(|p| p.id).collect();
let project_names: std::collections::HashMap<Uuid, String> = workspace_projects
.into_iter()
.map(|p| (p.id, p.name))
.collect();
let recent_activities = if project_ids.is_empty() {
Vec::new()
} else {
let activities = project_activity::Entity::find()
.filter(project_activity::Column::Project.is_in(project_ids.clone()))
.filter(project_activity::Column::IsPrivate.eq(false))
.order_by_desc(project_activity::Column::CreatedAt)
.limit(10)
.all(&self.db)
.await?;
// Collect actor IDs
let actor_ids: Vec<Uuid> = activities.iter().map(|a| a.actor).collect();
let actors = user::Entity::find()
.filter(user::Column::Uid.is_in(actor_ids))
.all(&self.db)
.await?;
let actor_map: std::collections::HashMap<Uuid, (String, Option<String>)> = actors
.into_iter()
.map(|u| {
(
u.uid,
(
u.display_name.or(Some(u.username)).unwrap_or_default(),
u.avatar_url,
),
)
})
.collect();
activities
.into_iter()
.map(|a| {
let (actor_name, actor_avatar) = actor_map
.get(&a.actor)
.cloned()
.unwrap_or_else(|| ("Unknown".to_string(), None));
WorkspaceActivityItem {
id: a.id,
project_name: project_names.get(&a.project).cloned().unwrap_or_default(),
event_type: a.event_type,
title: a.title,
content: a.content,
actor_name,
actor_avatar,
created_at: a.created_at,
}
})
.collect()
};
Ok(WorkspaceStatsResponse {
project_count: project_count as i64,
member_count: member_count as i64,
my_role: membership.map(|m| m.role),
recent_activities,
})
}
}
use uuid::Uuid;

View File

@ -1,121 +0,0 @@
use crate::AppService;
use crate::error::AppError;
use chrono::Utc;
use models::Decimal;
use models::WorkspaceRole;
use models::workspaces::workspace;
use models::workspaces::workspace_billing;
use models::workspaces::workspace_membership;
use sea_orm::*;
use serde::{Deserialize, Serialize};
use session::Session;
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct WorkspaceInitParams {
pub slug: String,
pub name: String,
pub description: Option<String>,
}
impl AppService {
pub async fn workspace_init(
&self,
ctx: &Session,
params: WorkspaceInitParams,
) -> Result<workspace::Model, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let user = self.utils_find_user_by_uid(user_uid).await?;
// Validate slug format: alphanumeric, dashes, underscores
if !params
.slug
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(AppError::BadRequest(
"Slug must contain only letters, numbers, hyphens and underscores".to_string(),
));
}
// Check slug uniqueness
if workspace::Entity::find()
.filter(workspace::Column::Slug.eq(&params.slug))
.filter(workspace::Column::DeletedAt.is_null())
.one(&self.db)
.await?
.is_some()
{
return Err(AppError::WorkspaceSlugAlreadyExists);
}
// Check name uniqueness
if workspace::Entity::find()
.filter(workspace::Column::Name.eq(&params.name))
.filter(workspace::Column::DeletedAt.is_null())
.one(&self.db)
.await?
.is_some()
{
return Err(AppError::WorkspaceNameAlreadyExists);
}
let txn = self.db.begin().await?;
let ws = workspace::ActiveModel {
id: Set(Uuid::now_v7()),
slug: Set(params.slug),
name: Set(params.name),
description: Set(params.description),
avatar_url: Set(None),
plan: Set("free".to_string()),
billing_email: Set(None),
stripe_customer_id: Set(None),
stripe_subscription_id: Set(None),
plan_expires_at: Set(None),
deleted_at: Set(None),
created_at: Set(Utc::now()),
updated_at: Set(Utc::now()),
};
let ws = ws.insert(&txn).await?;
let membership = workspace_membership::ActiveModel {
id: Default::default(),
workspace_id: Set(ws.id),
user_id: Set(user.uid),
role: Set(WorkspaceRole::Owner.to_string()),
status: Set("active".to_string()),
invited_by: Set(None),
joined_at: Set(Utc::now()),
invite_token: Set(None),
invite_expires_at: Set(None),
};
membership.insert(&txn).await?;
// Create billing record — only first workspace gets $30 initial balance
let existing_workspaces = workspace_membership::Entity::find()
.filter(workspace_membership::Column::UserId.eq(user.uid))
.filter(workspace_membership::Column::Status.eq("active"))
.all(&self.db)
.await?;
let initial_balance = if existing_workspaces.is_empty() {
Decimal::from_f64_retain(30.0).unwrap_or(Decimal::ZERO)
} else {
Decimal::ZERO
};
let billing = workspace_billing::ActiveModel {
workspace_id: Set(ws.id),
balance: Set(initial_balance),
currency: Set("USD".to_string()),
monthly_quota: Set(Decimal::from_f64_retain(100.0).unwrap_or(Decimal::ZERO)),
total_spent: Set(Decimal::ZERO),
updated_at: Set(Utc::now()),
created_at: Set(Utc::now()),
};
billing.insert(&txn).await?;
txn.commit().await?;
Ok(ws)
}
}
use uuid::Uuid;

View File

@ -1,632 +0,0 @@
use crate::AppService;
use crate::error::AppError;
use chrono::{Duration, Utc};
use email::EmailMessage;
use models::WorkspaceRole;
use models::users::{user, user_email};
use models::workspaces::workspace;
use models::workspaces::workspace_membership;
use sea_orm::*;
use serde::{Deserialize, Serialize};
use session::Session;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WorkspaceMemberInfo {
pub user_id: Uuid,
pub username: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub role: String,
pub joined_at: chrono::DateTime<Utc>,
/// Username of the person who invited this member.
pub invited_by_username: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct PendingInvitationInfo {
pub user_id: Uuid,
pub username: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub email: Option<String>,
pub role: String,
pub invited_by_username: Option<String>,
pub invited_at: chrono::DateTime<Utc>,
pub expires_at: Option<chrono::DateTime<Utc>>,
}
/// Invitation received by the current user (workspace invitation for self).
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MyWorkspaceInvitation {
pub workspace_id: Uuid,
pub workspace_slug: String,
pub workspace_name: String,
pub role: String,
pub invited_by_username: Option<String>,
pub invited_at: chrono::DateTime<Utc>,
pub expires_at: Option<chrono::DateTime<Utc>>,
}
/// Request body for accepting workspace invitation by slug.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WorkspaceAcceptBySlugParams {
pub slug: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WorkspaceMembersResponse {
pub members: Vec<WorkspaceMemberInfo>,
pub total: u64,
pub page: u64,
pub per_page: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WorkspaceInviteParams {
pub email: String,
pub role: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WorkspaceInviteAcceptParams {
pub token: String,
}
impl AppService {
pub async fn workspace_members(
&self,
ctx: &Session,
workspace_slug: String,
page: Option<u64>,
per_page: Option<u64>,
) -> Result<WorkspaceMembersResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.utils_find_workspace_by_slug(workspace_slug).await?;
// Check membership
let _ = self
.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Member])
.await;
let page = page.unwrap_or(1);
let per_page = per_page.unwrap_or(20);
let memberships = workspace_membership::Entity::find()
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
.filter(workspace_membership::Column::Status.eq("active"))
.order_by_desc(workspace_membership::Column::JoinedAt)
.paginate(&self.db, per_page)
.fetch_page(page - 1)
.await?;
let total = workspace_membership::Entity::find()
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
.filter(workspace_membership::Column::Status.eq("active"))
.count(&self.db)
.await?;
let user_ids: Vec<Uuid> = memberships.iter().map(|m| m.user_id).collect();
let users = user::Entity::find()
.filter(user::Column::Uid.is_in(user_ids))
.all(&self.db)
.await?;
// Collect invited_by user IDs
let inviter_ids: Vec<Uuid> = memberships.iter().filter_map(|m| m.invited_by).collect();
let inviters = if !inviter_ids.is_empty() {
user::Entity::find()
.filter(user::Column::Uid.is_in(inviter_ids))
.all(&self.db)
.await?
} else {
vec![]
};
let members: Vec<WorkspaceMemberInfo> = memberships
.into_iter()
.filter_map(|m| {
let u = users.iter().find(|u| u.uid == m.user_id)?;
let invited_by_username = m.invited_by.and_then(|uid| {
inviters
.iter()
.find(|i| i.uid == uid)
.map(|i| i.username.clone())
});
Some(WorkspaceMemberInfo {
user_id: u.uid,
username: u.username.clone(),
display_name: u.display_name.clone(),
avatar_url: u.avatar_url.clone(),
role: m.role,
joined_at: m.joined_at,
invited_by_username,
})
})
.collect();
Ok(WorkspaceMembersResponse {
members,
total,
page,
per_page,
})
}
/// List pending (invited but not accepted) memberships for a workspace.
pub async fn workspace_pending_invitations(
&self,
ctx: &Session,
workspace_slug: String,
) -> Result<Vec<PendingInvitationInfo>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.utils_find_workspace_by_slug(workspace_slug).await?;
self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Admin])
.await?;
let pending = workspace_membership::Entity::find()
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
.filter(workspace_membership::Column::Status.eq("pending"))
.order_by_desc(workspace_membership::Column::JoinedAt)
.all(&self.db)
.await?;
let user_ids: Vec<Uuid> = pending.iter().map(|m| m.user_id).collect();
let users = if !user_ids.is_empty() {
user::Entity::find()
.filter(user::Column::Uid.is_in(user_ids.clone()))
.all(&self.db)
.await?
} else {
vec![]
};
// Get email addresses for invited users
let emails: Vec<(Uuid, String)> = user_email::Entity::find()
.filter(user_email::Column::User.is_in(user_ids))
.all(&self.db)
.await?
.into_iter()
.map(|e| (e.user, e.email))
.collect();
// Get inviter usernames
let inviter_ids: Vec<Uuid> = pending.iter().filter_map(|m| m.invited_by).collect();
let inviters = if !inviter_ids.is_empty() {
user::Entity::find()
.filter(user::Column::Uid.is_in(inviter_ids))
.all(&self.db)
.await?
} else {
vec![]
};
let invitations: Vec<PendingInvitationInfo> = pending
.into_iter()
.filter_map(|m| {
let u = users.iter().find(|u| u.uid == m.user_id)?;
let email = emails
.iter()
.find(|(uid, _)| *uid == m.user_id)
.map(|(_, e)| e.clone());
let invited_by_username = m.invited_by.and_then(|uid| {
inviters
.iter()
.find(|i| i.uid == uid)
.map(|i| i.username.clone())
});
Some(PendingInvitationInfo {
user_id: u.uid,
username: u.username.clone(),
display_name: u.display_name.clone(),
avatar_url: u.avatar_url.clone(),
email,
role: m.role,
invited_by_username,
invited_at: m.joined_at,
expires_at: m.invite_expires_at,
})
})
.collect();
Ok(invitations)
}
/// Cancel a pending invitation (remove the pending membership record).
pub async fn workspace_cancel_invitation(
&self,
ctx: &Session,
workspace_slug: String,
target_user_id: Uuid,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.utils_find_workspace_by_slug(workspace_slug).await?;
self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Admin])
.await?;
let deleted = workspace_membership::Entity::delete_many()
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
.filter(workspace_membership::Column::UserId.eq(target_user_id))
.filter(workspace_membership::Column::Status.eq("pending"))
.exec(&self.db)
.await?;
if deleted.rows_affected == 0 {
return Err(AppError::NotFound("Invitation not found".to_string()));
}
Ok(())
}
pub async fn workspace_invite_member(
&self,
ctx: &Session,
workspace_slug: String,
params: WorkspaceInviteParams,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self
.utils_find_workspace_by_slug(workspace_slug.clone())
.await?;
// Only owner/admin can invite
self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Admin])
.await?;
let inviter = self.utils_find_user_by_uid(user_uid).await?;
// Find target user by email
let target_email = user_email::Entity::find()
.filter(user_email::Column::Email.eq(&params.email))
.one(&self.db)
.await?
.ok_or(AppError::UserNotFound)?;
let target_user = self.utils_find_user_by_uid(target_email.user).await?;
// Check if already a member
if workspace_membership::Entity::find()
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
.filter(workspace_membership::Column::UserId.eq(target_user.uid))
.filter(workspace_membership::Column::Status.eq("active"))
.one(&self.db)
.await?
.is_some()
{
return Err(AppError::BadRequest("User is already a member".to_string()));
}
// Generate invite token
let token = generate_invite_token();
let expires_at = Utc::now() + Duration::days(7);
// Create or update pending membership
let existing: Option<workspace_membership::Model> = workspace_membership::Entity::find()
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
.filter(workspace_membership::Column::UserId.eq(target_user.uid))
.one(&self.db)
.await?;
let txn = self.db.begin().await?;
match existing {
Some(m) => {
let mut m: workspace_membership::ActiveModel = m.into();
m.invite_token = Set(Some(token.clone()));
m.invite_expires_at = Set(Some(expires_at));
m.invited_by = Set(Some(user_uid));
m.role = Set(params
.role
.unwrap_or_else(|| WorkspaceRole::Member.to_string()));
m.status = Set("pending".to_string());
m.update(&txn).await?;
}
None => {
let m = workspace_membership::ActiveModel {
id: Default::default(),
workspace_id: Set(ws.id),
user_id: Set(target_user.uid),
role: Set(params
.role
.unwrap_or_else(|| WorkspaceRole::Member.to_string())),
status: Set("pending".to_string()),
invited_by: Set(Some(user_uid)),
joined_at: Set(Utc::now()),
invite_token: Set(Some(token.clone())),
invite_expires_at: Set(Some(expires_at)),
};
m.insert(&txn).await?;
}
}
txn.commit().await?;
// Send invitation email
let domain = self
.config
.main_domain()
.map_err(|_| AppError::DoMainNotSet)?;
let invite_link = format!(
"{}/auth/accept-workspace-invite?token={}",
domain,
token.clone()
);
let envelope = EmailMessage {
to: target_email.email.clone(),
subject: format!("You've been invited to join {}", ws.name),
body: format!(
"Hello {},\n\n\
{} has invited you to join the workspace \"{}\".\n\n\
Click the link below to accept the invitation:\n\
{}\n\n\
This invitation expires in 7 days.\n\n\
Best regards,\n\
GitDataAI Team",
target_user.username, inviter.username, ws.name, invite_link
),
};
self.email.send(envelope).await.map_err(|e| {
AppError::InternalServerError(format!("Failed to send invitation email: {}", e))
})?;
// Send in-app notification + push notification to the invitee
self.send_push_to_user(
target_user.uid,
crate::push::PushPayload {
title: format!("Workspace invitation: {}", ws.name),
body: format!("{} invited you to join the workspace \"{}\"", inviter.username, ws.name),
url: Some(format!("/workspaces/{}/invitations", ws.slug)),
icon: None,
},
);
let _ = self
.room
.notification_create(room::NotificationCreateRequest {
notification_type: room::NotificationType::WorkspaceInvitation,
user_id: target_user.uid,
title: format!("{} invited you to join \"{}\"", inviter.username, ws.name),
content: None,
room_id: None,
project_id: Default::default(), // workspace invitations don't have a project_id
related_message_id: None,
related_user_id: Some(user_uid),
related_room_id: None,
metadata: Some(serde_json::json!({
"workspace_id": ws.id,
"workspace_name": ws.name,
"workspace_slug": ws.slug,
"inviter_uid": user_uid,
})),
expires_at: None,
})
.await;
Ok(())
}
pub async fn workspace_accept_invitation(
&self,
ctx: &Session,
params: WorkspaceInviteAcceptParams,
) -> Result<workspace::Model, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let membership = workspace_membership::Entity::find()
.filter(workspace_membership::Column::InviteToken.eq(&params.token))
.one(&self.db)
.await?
.ok_or(AppError::WorkspaceInviteTokenInvalid)?;
if membership.user_id != user_uid {
return Err(AppError::WorkspaceInviteTokenInvalid);
}
if membership.status == "active" {
return Err(AppError::WorkspaceInviteAlreadyAccepted);
}
if let Some(expires_at) = membership.invite_expires_at {
if Utc::now() > expires_at {
return Err(AppError::WorkspaceInviteExpired);
}
}
let ws_id = membership.workspace_id;
let mut m: workspace_membership::ActiveModel = membership.into();
m.status = Set("active".to_string());
m.invite_token = Set(None);
m.invite_expires_at = Set(None);
m.update(&self.db).await?;
self.utils_find_workspace_by_id(ws_id).await
}
/// List all pending workspace invitations for the current user (where user is invitee).
pub async fn workspace_my_pending_invitations(
&self,
ctx: &Session,
) -> Result<Vec<MyWorkspaceInvitation>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let pending = workspace_membership::Entity::find()
.filter(workspace_membership::Column::UserId.eq(user_uid))
.filter(workspace_membership::Column::Status.eq("pending"))
.order_by_desc(workspace_membership::Column::JoinedAt)
.all(&self.db)
.await?;
if pending.is_empty() {
return Ok(vec![]);
}
// Fetch workspace info
let ws_ids: Vec<Uuid> = pending.iter().map(|m| m.workspace_id).collect();
let workspaces: std::collections::HashMap<Uuid, workspace::Model> = workspace::Entity::find()
.filter(workspace::Column::Id.is_in(ws_ids.clone()))
.all(&self.db)
.await?
.into_iter()
.map(|w| (w.id, w))
.collect();
// Fetch inviter usernames
let inviter_ids: Vec<Uuid> = pending.iter().filter_map(|m| m.invited_by).collect();
let inviters: std::collections::HashMap<Uuid, String> = if !inviter_ids.is_empty() {
user::Entity::find()
.filter(user::Column::Uid.is_in(inviter_ids))
.all(&self.db)
.await?
.into_iter()
.map(|u| (u.uid, u.username))
.collect()
} else {
std::collections::HashMap::new()
};
let invitations: Vec<MyWorkspaceInvitation> = pending
.into_iter()
.filter_map(|m| {
let ws = workspaces.get(&m.workspace_id)?;
let invited_by_username = m.invited_by.and_then(|uid| inviters.get(&uid).cloned());
Some(MyWorkspaceInvitation {
workspace_id: m.workspace_id,
workspace_slug: ws.slug.clone(),
workspace_name: ws.name.clone(),
role: m.role,
invited_by_username,
invited_at: m.joined_at,
expires_at: m.invite_expires_at,
})
})
.collect();
Ok(invitations)
}
/// Accept a workspace invitation by slug (for the current user).
pub async fn workspace_accept_invitation_by_slug(
&self,
ctx: &Session,
params: WorkspaceAcceptBySlugParams,
) -> Result<workspace::Model, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.utils_find_workspace_by_slug(params.slug.clone()).await?;
let membership = workspace_membership::Entity::find()
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
.filter(workspace_membership::Column::UserId.eq(user_uid))
.one(&self.db)
.await?
.ok_or(AppError::NotFound(
"No pending invitation found for this workspace".to_string(),
))?;
if membership.status == "active" {
return Err(AppError::WorkspaceInviteAlreadyAccepted);
}
if let Some(expires_at) = membership.invite_expires_at {
if Utc::now() > expires_at {
return Err(AppError::WorkspaceInviteExpired);
}
}
let mut m: workspace_membership::ActiveModel = membership.into();
m.status = Set("active".to_string());
m.invite_token = Set(None);
m.invite_expires_at = Set(None);
m.update(&self.db).await?;
Ok(ws)
}
pub async fn workspace_remove_member(
&self,
ctx: &Session,
workspace_slug: String,
target_user_id: Uuid,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.utils_find_workspace_by_slug(workspace_slug).await?;
// Only owner/admin can remove members
self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Admin])
.await?;
// Cannot remove owner
let target_membership = workspace_membership::Entity::find()
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
.filter(workspace_membership::Column::UserId.eq(target_user_id))
.one(&self.db)
.await?
.ok_or(AppError::NotWorkspaceMember)?;
if target_membership.role == WorkspaceRole::Owner.to_string() {
return Err(AppError::BadRequest(
"Cannot remove workspace owner".to_string(),
));
}
workspace_membership::Entity::delete_many()
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
.filter(workspace_membership::Column::UserId.eq(target_user_id))
.exec(&self.db)
.await?;
Ok(())
}
pub async fn workspace_update_member_role(
&self,
ctx: &Session,
workspace_slug: String,
target_user_id: Uuid,
new_role: String,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.utils_find_workspace_by_slug(workspace_slug).await?;
self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Admin])
.await?;
let target_role: WorkspaceRole = new_role.parse().map_err(|_| AppError::RoleParseError)?;
let membership = workspace_membership::Entity::find()
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
.filter(workspace_membership::Column::UserId.eq(target_user_id))
.one(&self.db)
.await?
.ok_or(AppError::NotWorkspaceMember)?;
// Cannot demote owner
if membership.role == WorkspaceRole::Owner.to_string()
&& target_role != WorkspaceRole::Owner
{
return Err(AppError::BadRequest(
"Cannot demote workspace owner".to_string(),
));
}
let mut m: workspace_membership::ActiveModel = membership.into();
m.role = Set(new_role);
m.update(&self.db).await?;
Ok(())
}
}
fn generate_invite_token() -> String {
use rand::RngExt;
use rand::distr::Alphanumeric;
let token: String = rand::rng()
.sample_iter(Alphanumeric)
.take(64)
.map(char::from)
.collect();
format!("ws_inv_{}", token)
}

View File

@ -1,6 +0,0 @@
pub mod alert;
pub mod billing;
pub mod info;
pub mod init;
pub mod members;
pub mod settings;

View File

@ -1,83 +0,0 @@
use crate::AppService;
use crate::error::AppError;
use chrono::Utc;
use models::workspaces::{WorkspaceRole, workspace};
use sea_orm::*;
use serde::{Deserialize, Serialize};
use session::Session;
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WorkspaceUpdateParams {
pub name: Option<String>,
pub description: Option<String>,
pub avatar_url: Option<String>,
pub billing_email: Option<String>,
}
impl AppService {
pub async fn workspace_update(
&self,
ctx: &Session,
workspace_slug: String,
params: WorkspaceUpdateParams,
) -> Result<workspace::Model, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self
.utils_find_workspace_by_slug(workspace_slug.clone())
.await?;
self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Admin])
.await?;
let ws_id = ws.id;
let mut m: workspace::ActiveModel = ws.into();
if let Some(name) = params.name {
// Check name uniqueness
if workspace::Entity::find()
.filter(workspace::Column::Name.eq(&name))
.filter(workspace::Column::DeletedAt.is_null())
.filter(workspace::Column::Id.ne(ws_id))
.one(&self.db)
.await?
.is_some()
{
return Err(AppError::WorkspaceNameAlreadyExists);
}
m.name = Set(name);
}
if let Some(description) = params.description {
m.description = Set(Some(description));
}
if let Some(avatar_url) = params.avatar_url {
m.avatar_url = Set(Some(avatar_url));
}
if let Some(billing_email) = params.billing_email {
m.billing_email = Set(Some(billing_email));
}
m.updated_at = Set(Utc::now());
m.update(&self.db).await.map_err(Into::into)
}
/// Soft-delete a workspace. Only owner can delete.
pub async fn workspace_delete(
&self,
ctx: &Session,
workspace_slug: String,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.utils_find_workspace_by_slug(workspace_slug).await?;
self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Owner])
.await?;
let mut m: workspace::ActiveModel = ws.into();
m.deleted_at = Set(Some(Utc::now()));
m.updated_at = Set(Utc::now());
m.update(&self.db).await?;
Ok(())
}
}