feat(billing): workflow points gating + charge
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+87
-46
@@ -13,10 +13,9 @@ use crate::{
|
|||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
extract::{
|
extract::{
|
||||||
DefaultBodyLimit,
|
|
||||||
multipart::Multipart,
|
multipart::Multipart,
|
||||||
ws::{Message, WebSocketUpgrade},
|
ws::{Message, WebSocketUpgrade},
|
||||||
FromRequest, Path, Query, State,
|
DefaultBodyLimit, FromRequest, Path, Query, State,
|
||||||
},
|
},
|
||||||
http::{header, HeaderMap, HeaderValue, Method, StatusCode, Uri},
|
http::{header, HeaderMap, HeaderValue, Method, StatusCode, Uri},
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
@@ -1311,7 +1310,10 @@ fn is_boosapi_extension_node_type(node_type: &str) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workflow_node_defs_with_models(models: &[String], include_compat_core: bool) -> Map<String, Value> {
|
fn workflow_node_defs_with_models(
|
||||||
|
models: &[String],
|
||||||
|
include_compat_core: bool,
|
||||||
|
) -> Map<String, Value> {
|
||||||
let core_defs = include_compat_core
|
let core_defs = include_compat_core
|
||||||
.then(core_node_definitions)
|
.then(core_node_definitions)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
@@ -1865,9 +1867,7 @@ fn cache_control_for_path(path: &StdPath) -> &'static str {
|
|||||||
.to_ascii_lowercase();
|
.to_ascii_lowercase();
|
||||||
match ext.as_str() {
|
match ext.as_str() {
|
||||||
"html" | "js" | "mjs" | "css" => "no-cache",
|
"html" | "js" | "mjs" | "css" => "no-cache",
|
||||||
"woff2" | "png" | "jpg" | "jpeg" | "webp" | "svg" => {
|
"woff2" | "png" | "jpg" | "jpeg" | "webp" | "svg" => "public, max-age=604800",
|
||||||
"public, max-age=604800"
|
|
||||||
}
|
|
||||||
_ => "public, max-age=3600",
|
_ => "public, max-age=3600",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1921,7 +1921,8 @@ async fn get_setting(
|
|||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let _ = auth_caller(&state, &headers).await?;
|
let _ = auth_caller(&state, &headers).await?;
|
||||||
if state.config.comfyui.uses_cloud_backend() && id == "Comfy.Workflow.ShowMissingModelsWarning" {
|
if state.config.comfyui.uses_cloud_backend() && id == "Comfy.Workflow.ShowMissingModelsWarning"
|
||||||
|
{
|
||||||
return Ok(Json(Value::Bool(false)));
|
return Ok(Json(Value::Bool(false)));
|
||||||
}
|
}
|
||||||
Ok(Json(Value::Null))
|
Ok(Json(Value::Null))
|
||||||
@@ -2936,7 +2937,10 @@ fn repair_resize_image_mask_node_inputs_without_workflow(
|
|||||||
inputs
|
inputs
|
||||||
.entry("resize_type".to_string())
|
.entry("resize_type".to_string())
|
||||||
.or_insert_with(|| Value::String("scale dimensions".to_string()));
|
.or_insert_with(|| Value::String("scale dimensions".to_string()));
|
||||||
inputs.insert("resize_type.crop".to_string(), Value::String("center".to_string()));
|
inputs.insert(
|
||||||
|
"resize_type.crop".to_string(),
|
||||||
|
Value::String("center".to_string()),
|
||||||
|
);
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
node_id = %node_id,
|
node_id = %node_id,
|
||||||
"repaired ResizeImageMaskNode crop prompt input without workflow metadata"
|
"repaired ResizeImageMaskNode crop prompt input without workflow metadata"
|
||||||
@@ -3186,14 +3190,11 @@ fn dynamic_combo_value_matches_field(value: &Value, field: &DynamicComboField) -
|
|||||||
.as_str()
|
.as_str()
|
||||||
.is_some_and(|text| field.options.iter().any(|option| option == text)),
|
.is_some_and(|text| field.options.iter().any(|option| option == text)),
|
||||||
"INT" => dynamic_combo_value_as_i64(value).is_some_and(|number| {
|
"INT" => dynamic_combo_value_as_i64(value).is_some_and(|number| {
|
||||||
field
|
field.min.is_none_or(|min| (number as f64) >= min)
|
||||||
.min
|
|
||||||
.is_none_or(|min| (number as f64) >= min)
|
|
||||||
&& field.max.is_none_or(|max| (number as f64) <= max)
|
&& field.max.is_none_or(|max| (number as f64) <= max)
|
||||||
}),
|
}),
|
||||||
"FLOAT" => dynamic_combo_value_as_f64(value).is_some_and(|number| {
|
"FLOAT" => dynamic_combo_value_as_f64(value).is_some_and(|number| {
|
||||||
field.min.is_none_or(|min| number >= min)
|
field.min.is_none_or(|min| number >= min) && field.max.is_none_or(|max| number <= max)
|
||||||
&& field.max.is_none_or(|max| number <= max)
|
|
||||||
}),
|
}),
|
||||||
"BOOLEAN" => dynamic_combo_value_as_bool(value).is_some(),
|
"BOOLEAN" => dynamic_combo_value_as_bool(value).is_some(),
|
||||||
_ => true,
|
_ => true,
|
||||||
@@ -3239,28 +3240,42 @@ fn dynamic_combo_value_as_i64(value: &Value) -> Option<i64> {
|
|||||||
.as_i64()
|
.as_i64()
|
||||||
.or_else(|| value.as_u64().and_then(|number| i64::try_from(number).ok()))
|
.or_else(|| value.as_u64().and_then(|number| i64::try_from(number).ok()))
|
||||||
.or_else(|| value.as_f64().map(|number| number.round() as i64))
|
.or_else(|| value.as_f64().map(|number| number.round() as i64))
|
||||||
.or_else(|| value.as_str().and_then(|text| text.trim().parse::<i64>().ok()))
|
.or_else(|| {
|
||||||
|
value
|
||||||
|
.as_str()
|
||||||
|
.and_then(|text| text.trim().parse::<i64>().ok())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dynamic_combo_value_as_f64(value: &Value) -> Option<f64> {
|
fn dynamic_combo_value_as_f64(value: &Value) -> Option<f64> {
|
||||||
value
|
value.as_f64().or_else(|| {
|
||||||
.as_f64()
|
value
|
||||||
.or_else(|| value.as_str().and_then(|text| text.trim().parse::<f64>().ok()))
|
.as_str()
|
||||||
|
.and_then(|text| text.trim().parse::<f64>().ok())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dynamic_combo_value_as_bool(value: &Value) -> Option<bool> {
|
fn dynamic_combo_value_as_bool(value: &Value) -> Option<bool> {
|
||||||
value.as_bool().or_else(|| {
|
value.as_bool().or_else(|| {
|
||||||
value.as_str().and_then(|text| match text.trim().to_ascii_lowercase().as_str() {
|
value
|
||||||
"true" | "1" | "yes" | "on" => Some(true),
|
.as_str()
|
||||||
"false" | "0" | "no" | "off" => Some(false),
|
.and_then(|text| match text.trim().to_ascii_lowercase().as_str() {
|
||||||
_ => None,
|
"true" | "1" | "yes" | "on" => Some(true),
|
||||||
})
|
"false" | "0" | "no" | "off" => Some(false),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clamp_i64_to_field(value: i64, field: &DynamicComboField) -> i64 {
|
fn clamp_i64_to_field(value: i64, field: &DynamicComboField) -> i64 {
|
||||||
let min = field.min.map(|value| value.ceil() as i64).unwrap_or(i64::MIN);
|
let min = field
|
||||||
let max = field.max.map(|value| value.floor() as i64).unwrap_or(i64::MAX);
|
.min
|
||||||
|
.map(|value| value.ceil() as i64)
|
||||||
|
.unwrap_or(i64::MIN);
|
||||||
|
let max = field
|
||||||
|
.max
|
||||||
|
.map(|value| value.floor() as i64)
|
||||||
|
.unwrap_or(i64::MAX);
|
||||||
value.clamp(min, max)
|
value.clamp(min, max)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3284,9 +3299,7 @@ fn repair_wan2_api_nodes(prompt: &mut Value) {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string();
|
.to_string();
|
||||||
let field_names = match class_type.as_str() {
|
let field_names = match class_type.as_str() {
|
||||||
"Wan2ImageToVideoApi" => {
|
"Wan2ImageToVideoApi" => &["prompt", "negative_prompt", "resolution", "duration"][..],
|
||||||
&["prompt", "negative_prompt", "resolution", "duration"][..]
|
|
||||||
}
|
|
||||||
"Wan2TextToVideoApi" | "Wan2ReferenceVideoApi" => &[
|
"Wan2TextToVideoApi" | "Wan2ReferenceVideoApi" => &[
|
||||||
"prompt",
|
"prompt",
|
||||||
"negative_prompt",
|
"negative_prompt",
|
||||||
@@ -3789,7 +3802,11 @@ async fn upload_image_to_native_backend(
|
|||||||
let content_type = headers
|
let content_type = headers
|
||||||
.get(header::CONTENT_TYPE)
|
.get(header::CONTENT_TYPE)
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.filter(|value| value.to_ascii_lowercase().starts_with("multipart/form-data"))
|
.filter(|value| {
|
||||||
|
value
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.starts_with("multipart/form-data")
|
||||||
|
})
|
||||||
.ok_or_else(|| AppError::BadRequest("multipart/form-data content-type is required".into()))?
|
.ok_or_else(|| AppError::BadRequest("multipart/form-data content-type is required".into()))?
|
||||||
.to_string();
|
.to_string();
|
||||||
let response = native_workflow_request(state, Method::POST, "/upload/image", None)
|
let response = native_workflow_request(state, Method::POST, "/upload/image", None)
|
||||||
@@ -4179,10 +4196,16 @@ async fn native_workflow_raw_response(
|
|||||||
) -> AppResult<Response> {
|
) -> AppResult<Response> {
|
||||||
let is_prompt_post = method == Method::POST && path.split('?').next() == Some("/prompt");
|
let is_prompt_post = method == Method::POST && path.split('?').next() == Some("/prompt");
|
||||||
let mut request = native_workflow_request(state, method, path, None);
|
let mut request = native_workflow_request(state, method, path, None);
|
||||||
if let Some(content_type) = headers.get(header::CONTENT_TYPE).and_then(|value| value.to_str().ok()) {
|
if let Some(content_type) = headers
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
{
|
||||||
request = request.header(reqwest::header::CONTENT_TYPE, content_type);
|
request = request.header(reqwest::header::CONTENT_TYPE, content_type);
|
||||||
}
|
}
|
||||||
if let Some(accept) = headers.get(header::ACCEPT).and_then(|value| value.to_str().ok()) {
|
if let Some(accept) = headers
|
||||||
|
.get(header::ACCEPT)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
{
|
||||||
request = request.header(reqwest::header::ACCEPT, accept);
|
request = request.header(reqwest::header::ACCEPT, accept);
|
||||||
}
|
}
|
||||||
if !body.is_empty() {
|
if !body.is_empty() {
|
||||||
@@ -4861,7 +4884,10 @@ async fn experiment_models_in_folder(
|
|||||||
native_items.extend(items);
|
native_items.extend(items);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Ok(Json(merge_model_payload_items(api_model_items, native_items)));
|
return Ok(Json(merge_model_payload_items(
|
||||||
|
api_model_items,
|
||||||
|
native_items,
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
let path = format!("/experiment/models/{}", simple_urlencode(&folder));
|
let path = format!("/experiment/models/{}", simple_urlencode(&folder));
|
||||||
return native_workflow_json_cached(
|
return native_workflow_json_cached(
|
||||||
@@ -4909,7 +4935,10 @@ async fn experiment_models_in_folder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Ok(Json(merge_model_payload_items(native_items, api_model_items)));
|
return Ok(Json(merge_model_payload_items(
|
||||||
|
native_items,
|
||||||
|
api_model_items,
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
let caller =
|
let caller =
|
||||||
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
|
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
|
||||||
@@ -4926,7 +4955,10 @@ async fn experiment_models_in_folder(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Json(merge_model_payload_items(api_model_items, local_items)))
|
Ok(Json(merge_model_payload_items(
|
||||||
|
api_model_items,
|
||||||
|
local_items,
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn view_metadata(
|
async fn view_metadata(
|
||||||
@@ -5228,9 +5260,14 @@ async fn workflow_context(state: &AppState, caller: &WorkflowCaller) -> AppResul
|
|||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(&*state.db)
|
.fetch_optional(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
maybe_key
|
// Must run on the user's own key so consumption is billed to them.
|
||||||
.map(|key| key.key)
|
// Falling back to the global builtin key would mix every user's
|
||||||
.unwrap_or_else(|| state.builtin_keys.comfyui_agent.1.clone())
|
// cost into one shared account, breaking per-user accounting.
|
||||||
|
maybe_key.map(|key| key.key).ok_or_else(|| {
|
||||||
|
AppError::Forbidden(
|
||||||
|
"当前用户没有可用的 API Key,无法计费执行工作流,请先创建 API Key".into(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
|
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
|
||||||
@@ -5242,6 +5279,7 @@ async fn workflow_context(state: &AppState, caller: &WorkflowCaller) -> AppResul
|
|||||||
user_id: caller_user_id(caller),
|
user_id: caller_user_id(caller),
|
||||||
bearer,
|
bearer,
|
||||||
base_url: format!("http://{}:{}/v1", host, state.config.server.port),
|
base_url: format!("http://{}:{}/v1", host, state.config.server.port),
|
||||||
|
charge_points: !caller_is_admin(caller),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5812,20 +5850,23 @@ mod tests {
|
|||||||
.filter_map(|item| item.get("name").and_then(Value::as_str))
|
.filter_map(|item| item.get("name").and_then(Value::as_str))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(names, vec!["wan2.1-t2v", "local-video-model.safetensors",]);
|
||||||
names,
|
|
||||||
vec![
|
|
||||||
"wan2.1-t2v",
|
|
||||||
"local-video-model.safetensors",
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workflow_ui_runtime_assets_revalidate_after_install() {
|
fn workflow_ui_runtime_assets_revalidate_after_install() {
|
||||||
assert_eq!(cache_control_for_path(StdPath::new("index.html")), "no-cache");
|
assert_eq!(
|
||||||
assert_eq!(cache_control_for_path(StdPath::new("assets/index.js")), "no-cache");
|
cache_control_for_path(StdPath::new("index.html")),
|
||||||
assert_eq!(cache_control_for_path(StdPath::new("assets/index.css")), "no-cache");
|
"no-cache"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cache_control_for_path(StdPath::new("assets/index.js")),
|
||||||
|
"no-cache"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cache_control_for_path(StdPath::new("assets/index.css")),
|
||||||
|
"no-cache"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cache_control_for_path(StdPath::new("assets/logo.svg")),
|
cache_control_for_path(StdPath::new("assets/logo.svg")),
|
||||||
"public, max-age=604800"
|
"public, max-age=604800"
|
||||||
|
|||||||
+110
-1
@@ -13,6 +13,30 @@ use std::{collections::HashMap, sync::Arc, time::Duration};
|
|||||||
|
|
||||||
const DEFAULT_TEXT_TO_VIDEO_MODEL: &str = "seedance-1-0-lite-t2v-250428";
|
const DEFAULT_TEXT_TO_VIDEO_MODEL: &str = "seedance-1-0-lite-t2v-250428";
|
||||||
|
|
||||||
|
/// Gate a workflow run on the caller's points balance. No-op when the caller
|
||||||
|
/// is not being charged (admin), has no user id, or the cost is zero.
|
||||||
|
async fn ensure_workflow_points(
|
||||||
|
db: &sqlx::SqlitePool,
|
||||||
|
user_id: Option<i64>,
|
||||||
|
charge_points: bool,
|
||||||
|
cost: f64,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
if !charge_points {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let Some(uid) = user_id else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if cost <= 0.0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let bal = crate::services::billing::ledger::points_balance(db, uid).await?;
|
||||||
|
if bal < cost {
|
||||||
|
return Err(AppError::Forbidden("工作流积分不足,请充值".into()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_video_model(value: &str) -> String {
|
fn normalize_video_model(value: &str) -> String {
|
||||||
match value.trim() {
|
match value.trim() {
|
||||||
"seedance-1-0-lite-t2v" => "seedance-1-0-lite-t2v-250428".to_string(),
|
"seedance-1-0-lite-t2v" => "seedance-1-0-lite-t2v-250428".to_string(),
|
||||||
@@ -81,6 +105,8 @@ pub async fn run_workflow_with_progress(
|
|||||||
.map_err(|err| AppError::BadRequest(format!("invalid workflow graph: {err}")))?;
|
.map_err(|err| AppError::BadRequest(format!("invalid workflow graph: {err}")))?;
|
||||||
let order = graph::validate_and_sort(&graph)?;
|
let order = graph::validate_and_sort(&graph)?;
|
||||||
let total = order.len().max(1);
|
let total = order.len().max(1);
|
||||||
|
let cost = state.config.comfyui.workflow_points_cost;
|
||||||
|
ensure_workflow_points(&state.db, ctx.user_id, ctx.charge_points, cost).await?;
|
||||||
let (run_db_id, run_public_id) =
|
let (run_db_id, run_public_id) =
|
||||||
storage::insert_run(state, workflow_id, ctx.user_id, &input_overrides).await?;
|
storage::insert_run(state, workflow_id, ctx.user_id, &input_overrides).await?;
|
||||||
emit_progress(
|
emit_progress(
|
||||||
@@ -161,6 +187,30 @@ pub async fn run_workflow_with_progress(
|
|||||||
|
|
||||||
let final_outputs = json!({ "nodes": store });
|
let final_outputs = json!({ "nodes": store });
|
||||||
storage::complete_run(state, run_db_id, &final_outputs).await?;
|
storage::complete_run(state, run_db_id, &final_outputs).await?;
|
||||||
|
if ctx.charge_points && cost > 0.0 {
|
||||||
|
if let Some(uid) = ctx.user_id {
|
||||||
|
if let Err(err) = crate::services::billing::ledger::apply_points_delta(
|
||||||
|
&state.db,
|
||||||
|
uid,
|
||||||
|
-cost,
|
||||||
|
"consume",
|
||||||
|
Some(&format!("workflow:{}", workflow_id)),
|
||||||
|
Some("system"),
|
||||||
|
Some("workflow_run"),
|
||||||
|
Some(&run_public_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
user_id = uid,
|
||||||
|
workflow_id,
|
||||||
|
run = %run_public_id,
|
||||||
|
error = %err,
|
||||||
|
"failed to charge workflow points after successful run"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
emit_progress(
|
emit_progress(
|
||||||
&progress,
|
&progress,
|
||||||
WorkflowProgressEvent::Finished {
|
WorkflowProgressEvent::Finished {
|
||||||
@@ -658,7 +708,11 @@ fn video_request_body(node: &WorkflowNode, prompt: String, image_url: Option<Str
|
|||||||
let mut body = Map::new();
|
let mut body = Map::new();
|
||||||
body.insert(
|
body.insert(
|
||||||
"model".to_string(),
|
"model".to_string(),
|
||||||
json!(normalize_video_model(¶m_str(node, "model", DEFAULT_TEXT_TO_VIDEO_MODEL))),
|
json!(normalize_video_model(¶m_str(
|
||||||
|
node,
|
||||||
|
"model",
|
||||||
|
DEFAULT_TEXT_TO_VIDEO_MODEL
|
||||||
|
))),
|
||||||
);
|
);
|
||||||
body.insert("prompt".to_string(), json!(prompt));
|
body.insert("prompt".to_string(), json!(prompt));
|
||||||
for key in ["ratio", "resolution"] {
|
for key in ["ratio", "resolution"] {
|
||||||
@@ -1088,3 +1142,58 @@ fn value_to_string(value: Option<&Value>) -> String {
|
|||||||
Some(other) => other.to_string(),
|
Some(other) => other.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::services::billing::ledger;
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
|
||||||
|
async fn mem_pool() -> sqlx::SqlitePool {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
ledger::ensure_ledger_schema(&pool).await.unwrap();
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ensure_workflow_points_skips_when_not_charging() {
|
||||||
|
let pool = mem_pool().await;
|
||||||
|
// charge_points=false (admin) + zero balance + cost 10 => Ok (no gating).
|
||||||
|
ensure_workflow_points(&pool, Some(9), false, 10.0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ensure_workflow_points_rejects_insufficient_balance() {
|
||||||
|
let pool = mem_pool().await;
|
||||||
|
// user 9 has zero points, cost 10 => Forbidden.
|
||||||
|
let err = ensure_workflow_points(&pool, Some(9), true, 10.0)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
match err {
|
||||||
|
AppError::Forbidden(msg) => assert!(msg.contains("积分不足"), "msg was: {msg}"),
|
||||||
|
other => panic!("expected Forbidden, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ensure_workflow_points_allows_sufficient_balance_and_zero_cost() {
|
||||||
|
let pool = mem_pool().await;
|
||||||
|
ledger::apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), None, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// Balance 100 >= cost 10 => Ok.
|
||||||
|
ensure_workflow_points(&pool, Some(9), true, 10.0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// cost 0 => noop Ok regardless of balance.
|
||||||
|
ensure_workflow_points(&pool, Some(9), true, 0.0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ pub struct RunContext {
|
|||||||
pub user_id: Option<i64>,
|
pub user_id: Option<i64>,
|
||||||
pub bearer: String,
|
pub bearer: String,
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
|
pub charge_points: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ async fn run_context(state: &AppState, caller: &WorkflowCaller) -> AppResult<Run
|
|||||||
user_id: caller_user_id(caller),
|
user_id: caller_user_id(caller),
|
||||||
bearer,
|
bearer,
|
||||||
base_url: format!("http://{}:{}/v1", host, state.config.server.port),
|
base_url: format!("http://{}:{}/v1", host, state.config.server.port),
|
||||||
|
charge_points: !caller_is_admin(caller),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user