fix(admin): 行业选择500修复 + 管理员切换订阅计划
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- fix(industry): list_industries SQL参数编号错位 — count查询和items查询 共用WHERE子句但参数从$3开始,sqlx bind按$1/$2顺序绑定导致500 - feat(billing): 新增 PUT /admin/accounts/:id/subscription 端点 (super_admin) 验证目标计划 → 取消当前订阅 → 创建新订阅(30天) → 同步配额 - feat(admin-v2): Accounts.tsx 编辑弹窗新增「订阅计划」选择区 显示所有活跃计划,保存时调用admin switch plan API
This commit is contained in:
@@ -7,6 +7,7 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth::types::AuthContext;
|
||||
use crate::auth::handlers::{log_operation, check_permission};
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
use crate::state::AppState;
|
||||
use super::service;
|
||||
@@ -115,6 +116,41 @@ pub async fn increment_usage_dimension(
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/v1/billing/payments — 创建支付订单
|
||||
|
||||
/// PUT /api/v1/admin/accounts/:id/subscription — 管理员切换用户订阅计划(仅 super_admin)
|
||||
pub async fn admin_switch_subscription(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Path(account_id): Path<String>,
|
||||
Json(req): Json<AdminSwitchPlanRequest>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
// 仅 super_admin 可操作
|
||||
check_permission(&ctx, "admin:full")?;
|
||||
|
||||
// 验证 plan_id 非空
|
||||
if req.plan_id.trim().is_empty() {
|
||||
return Err(SaasError::InvalidInput("plan_id 不能为空".into()));
|
||||
}
|
||||
|
||||
let sub = service::admin_switch_plan(&state.db, &account_id, &req.plan_id).await?;
|
||||
|
||||
log_operation(
|
||||
&state.db,
|
||||
&ctx.account_id,
|
||||
"billing.admin_switch_plan",
|
||||
"account",
|
||||
&account_id,
|
||||
Some(serde_json::json!({ "plan_id": req.plan_id })),
|
||||
None,
|
||||
).await.ok(); // 日志失败不影响主流程
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"success": true,
|
||||
"subscription": sub,
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/v1/billing/payments — 创建支付订单
|
||||
pub async fn create_payment(
|
||||
State(state): State<AppState>,
|
||||
|
||||
@@ -6,7 +6,7 @@ pub mod handlers;
|
||||
pub mod payment;
|
||||
pub mod invoice_pdf;
|
||||
|
||||
use axum::routing::{get, post};
|
||||
use axum::routing::{get, post, put};
|
||||
|
||||
/// 全部计费路由(用于 main.rs 一次性挂载)
|
||||
pub fn routes() -> axum::Router<crate::state::AppState> {
|
||||
@@ -51,3 +51,9 @@ pub fn mock_routes() -> axum::Router<crate::state::AppState> {
|
||||
.route("/api/v1/billing/mock-pay", get(handlers::mock_pay_page))
|
||||
.route("/api/v1/billing/mock-pay/confirm", post(handlers::mock_pay_confirm))
|
||||
}
|
||||
|
||||
/// 管理员计费路由(需 super_admin 权限)
|
||||
pub fn admin_routes() -> axum::Router<crate::state::AppState> {
|
||||
axum::Router::new()
|
||||
.route("/api/v1/admin/accounts/:id/subscription", put(handlers::admin_switch_subscription))
|
||||
}
|
||||
|
||||
@@ -300,6 +300,93 @@ pub async fn increment_dimension_by(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 管理员切换用户订阅计划(仅 super_admin 调用)
|
||||
///
|
||||
/// 1. 验证目标 plan_id 存在且 active
|
||||
/// 2. 取消用户当前 active 订阅
|
||||
/// 3. 创建新订阅(status=active, 30 天周期)
|
||||
/// 4. 更新当月 usage quota 的 max_* 列
|
||||
pub async fn admin_switch_plan(
|
||||
pool: &PgPool,
|
||||
account_id: &str,
|
||||
target_plan_id: &str,
|
||||
) -> SaasResult<Subscription> {
|
||||
// 1. 验证目标计划存在且 active
|
||||
let plan = get_plan(pool, target_plan_id).await?
|
||||
.ok_or_else(|| crate::error::SaasError::NotFound("目标计划不存在或已下架".into()))?;
|
||||
|
||||
// 2. 检查是否已订阅该计划
|
||||
if let Some(current_sub) = get_active_subscription(pool, account_id).await? {
|
||||
if current_sub.plan_id == target_plan_id {
|
||||
return Err(crate::error::SaasError::InvalidInput("用户已订阅该计划".into()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await
|
||||
.map_err(|e| crate::error::SaasError::Internal(format!("开启事务失败: {}", e)))?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
// 3. 取消当前活跃订阅
|
||||
sqlx::query(
|
||||
"UPDATE billing_subscriptions SET status = 'canceled', canceled_at = $1, updated_at = $1 \
|
||||
WHERE account_id = $2 AND status IN ('trial', 'active', 'past_due')"
|
||||
)
|
||||
.bind(&now)
|
||||
.bind(account_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// 4. 创建新订阅
|
||||
let sub_id = uuid::Uuid::new_v4().to_string();
|
||||
let period_start = now;
|
||||
let period_end = now + chrono::Duration::days(30);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO billing_subscriptions \
|
||||
(id, account_id, plan_id, status, current_period_start, current_period_end, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, 'active', $4, $5, $6, $6)"
|
||||
)
|
||||
.bind(&sub_id)
|
||||
.bind(account_id)
|
||||
.bind(&target_plan_id)
|
||||
.bind(&period_start)
|
||||
.bind(&period_end)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// 5. 同步当月 usage quota 的 max_* 列
|
||||
let limits: PlanLimits = serde_json::from_value(plan.limits.clone())
|
||||
.unwrap_or_else(|_| PlanLimits::free());
|
||||
sqlx::query(
|
||||
"UPDATE billing_usage_quotas SET max_input_tokens=$1, max_output_tokens=$2, \
|
||||
max_relay_requests=$3, max_hand_executions=$4, max_pipeline_runs=$5, updated_at=NOW() \
|
||||
WHERE account_id=$6 AND period_start = DATE_TRUNC('month', NOW())"
|
||||
)
|
||||
.bind(limits.max_input_tokens_monthly)
|
||||
.bind(limits.max_output_tokens_monthly)
|
||||
.bind(limits.max_relay_requests_monthly)
|
||||
.bind(limits.max_hand_executions_monthly)
|
||||
.bind(limits.max_pipeline_runs_monthly)
|
||||
.bind(account_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await
|
||||
.map_err(|e| crate::error::SaasError::Internal(format!("事务提交失败: {}", e)))?;
|
||||
|
||||
// 查询返回新订阅
|
||||
let sub = sqlx::query_as::<_, Subscription>(
|
||||
"SELECT * FROM billing_subscriptions WHERE id = $1"
|
||||
)
|
||||
.bind(&sub_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(sub)
|
||||
}
|
||||
|
||||
/// 检查用量配额
|
||||
///
|
||||
/// P1-7 修复: 从当前 Plan 读取限额(而非 stale 的 usage 表冗余列)
|
||||
|
||||
@@ -159,3 +159,9 @@ pub struct PaymentResult {
|
||||
pub pay_url: String,
|
||||
pub amount_cents: i32,
|
||||
}
|
||||
|
||||
/// 管理员切换计划请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AdminSwitchPlanRequest {
|
||||
pub plan_id: String,
|
||||
}
|
||||
|
||||
@@ -15,24 +15,48 @@ pub async fn list_industries(
|
||||
) -> SaasResult<PaginatedResponse<IndustryListItem>> {
|
||||
let (page, page_size, offset) = normalize_pagination(query.page, query.page_size);
|
||||
|
||||
// 动态构建参数化查询 — 所有用户输入通过 $N 绑定
|
||||
let mut where_parts: Vec<String> = vec!["1=1".to_string()];
|
||||
let mut param_idx = 3; // $1=LIMIT, $2=OFFSET, $3+=filters
|
||||
let status_param: Option<String> = query.status.clone();
|
||||
let source_param: Option<String> = query.source.clone();
|
||||
|
||||
// 构建 WHERE 条件 — 每个查询独立的参数编号
|
||||
let mut where_parts: Vec<String> = vec!["1=1".to_string()];
|
||||
|
||||
// count 查询:参数从 $1 开始
|
||||
let mut count_params: Vec<String> = Vec::new();
|
||||
let mut count_idx = 1;
|
||||
if status_param.is_some() {
|
||||
where_parts.push(format!("status = ${}", param_idx));
|
||||
param_idx += 1;
|
||||
count_params.push(format!("status = ${}", count_idx));
|
||||
count_idx += 1;
|
||||
}
|
||||
if source_param.is_some() {
|
||||
where_parts.push(format!("source = ${}", param_idx));
|
||||
param_idx += 1;
|
||||
count_params.push(format!("source = ${}", count_idx));
|
||||
count_idx += 1;
|
||||
}
|
||||
let where_sql = where_parts.join(" AND ");
|
||||
let count_where = if count_params.is_empty() {
|
||||
"1=1".to_string()
|
||||
} else {
|
||||
format!("1=1 AND {}", count_params.join(" AND "))
|
||||
};
|
||||
|
||||
// items 查询:$1=LIMIT, $2=OFFSET, $3+=filters
|
||||
let mut items_params: Vec<String> = Vec::new();
|
||||
let mut items_idx = 3;
|
||||
if status_param.is_some() {
|
||||
items_params.push(format!("status = ${}", items_idx));
|
||||
items_idx += 1;
|
||||
}
|
||||
if source_param.is_some() {
|
||||
items_params.push(format!("source = ${}", items_idx));
|
||||
items_idx += 1;
|
||||
}
|
||||
let items_where = if items_params.is_empty() {
|
||||
"1=1".to_string()
|
||||
} else {
|
||||
format!("1=1 AND {}", items_params.join(" AND "))
|
||||
};
|
||||
|
||||
// count 查询
|
||||
let count_sql = format!("SELECT COUNT(*) FROM industries WHERE {}", where_sql);
|
||||
let count_sql = format!("SELECT COUNT(*) FROM industries WHERE {}", count_where);
|
||||
let mut count_q = sqlx::query_scalar::<_, i64>(&count_sql);
|
||||
if let Some(ref s) = status_param { count_q = count_q.bind(s); }
|
||||
if let Some(ref s) = source_param { count_q = count_q.bind(s); }
|
||||
@@ -44,7 +68,7 @@ pub async fn list_industries(
|
||||
COALESCE(jsonb_array_length(keywords), 0) as keywords_count, \
|
||||
created_at, updated_at \
|
||||
FROM industries WHERE {} ORDER BY source, id LIMIT $1 OFFSET $2",
|
||||
where_sql
|
||||
items_where
|
||||
);
|
||||
let mut items_q = sqlx::query_as::<_, IndustryListItem>(&items_sql)
|
||||
.bind(page_size as i64)
|
||||
|
||||
@@ -359,6 +359,7 @@ async fn build_router(state: AppState) -> axum::Router {
|
||||
.merge(zclaw_saas::scheduled_task::routes())
|
||||
.merge(zclaw_saas::telemetry::routes())
|
||||
.merge(zclaw_saas::billing::routes())
|
||||
.merge(zclaw_saas::billing::admin_routes())
|
||||
.merge(zclaw_saas::knowledge::routes())
|
||||
.merge(zclaw_saas::industry::routes())
|
||||
.layer(middleware::from_fn_with_state(
|
||||
|
||||
Reference in New Issue
Block a user