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

- 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:
iven
2026-04-14 19:06:58 +08:00
parent d2a0c8efc0
commit 6721a1cc6e
8 changed files with 218 additions and 13 deletions

View File

@@ -9,6 +9,7 @@ import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { accountService } from '@/services/accounts'
import { industryService } from '@/services/industries'
import { billingService } from '@/services/billing'
import { PageHeader } from '@/components/PageHeader'
import type { AccountPublic } from '@/types'
@@ -70,6 +71,12 @@ export default function Accounts() {
}
}, [accountIndustries, editingId, form])
// 获取所有活跃计划(用于管理员切换)
const { data: plansData } = useQuery({
queryKey: ['billing-plans'],
queryFn: ({ signal }) => billingService.listPlans(signal),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) =>
accountService.update(id, data),
@@ -101,6 +108,14 @@ export default function Accounts() {
onError: (err: Error) => message.error(err.message || '行业授权更新失败'),
})
// 管理员切换用户计划
const switchPlanMutation = useMutation({
mutationFn: ({ accountId, planId }: { accountId: string; planId: string }) =>
billingService.adminSwitchPlan(accountId, planId),
onSuccess: () => message.success('计划切换成功'),
onError: (err: Error) => message.error(err.message || '计划切换失败'),
})
const columns: ProColumns<AccountPublic>[] = [
{ title: '用户名', dataIndex: 'username', width: 120, tooltip: '搜索用户名、邮箱或显示名' },
{ title: '显示名', dataIndex: 'display_name', width: 120, hideInSearch: true },
@@ -186,7 +201,7 @@ export default function Accounts() {
try {
// 更新基础信息
const { industry_ids, ...accountData } = values
const { industry_ids, plan_id, ...accountData } = values
await updateMutation.mutateAsync({ id: editingId, data: accountData })
// 更新行业授权(如果变更了)
@@ -201,6 +216,11 @@ export default function Accounts() {
queryClient.invalidateQueries({ queryKey: ['account-industries'] })
}
// 切换订阅计划(如果选择了新计划)
if (plan_id) {
await switchPlanMutation.mutateAsync({ accountId: editingId, planId: plan_id })
}
handleClose()
} catch {
// Errors handled by mutation onError callbacks
@@ -218,6 +238,11 @@ export default function Accounts() {
label: `${item.icon} ${item.name}`,
}))
const planOptions = (plansData || []).map((plan) => ({
value: plan.id,
label: `${plan.display_name}${(plan.price_cents / 100).toFixed(0)}/月)`,
}))
return (
<div>
<PageHeader title="账号管理" description="管理系统用户账号、角色、权限与行业授权" />
@@ -256,7 +281,7 @@ export default function Accounts() {
open={modalOpen}
onOk={handleSave}
onCancel={handleClose}
confirmLoading={updateMutation.isPending || setIndustriesMutation.isPending}
confirmLoading={updateMutation.isPending || setIndustriesMutation.isPending || switchPlanMutation.isPending}
width={560}
>
<Form form={form} layout="vertical" className="mt-4">
@@ -280,6 +305,21 @@ export default function Accounts() {
]} />
</Form.Item>
<Divider></Divider>
<Form.Item
name="plan_id"
label="切换计划"
extra="选择新计划后保存将立即切换。留空则不修改当前计划。"
>
<Select
allowClear
placeholder="不修改当前计划"
options={planOptions}
loading={!plansData}
/>
</Form.Item>
<Divider></Divider>
<Form.Item

View File

@@ -90,4 +90,9 @@ export const billingService = {
getPaymentStatus: (id: string, signal?: AbortSignal) =>
request.get<PaymentStatus>(`/billing/payments/${id}`, withSignal({}, signal))
.then((r) => r.data),
/** 管理员切换用户订阅计划 (super_admin only) */
adminSwitchPlan: (accountId: string, planId: string) =>
request.put<{ success: boolean; subscription: Subscription }>(`/admin/accounts/${accountId}/subscription`, { plan_id: planId })
.then((r) => r.data),
}

View File

@@ -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>,

View File

@@ -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))
}

View File

@@ -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 表冗余列)

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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(