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:
@@ -9,6 +9,7 @@ import type { ProColumns } from '@ant-design/pro-components'
|
|||||||
import { ProTable } from '@ant-design/pro-components'
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
import { accountService } from '@/services/accounts'
|
import { accountService } from '@/services/accounts'
|
||||||
import { industryService } from '@/services/industries'
|
import { industryService } from '@/services/industries'
|
||||||
|
import { billingService } from '@/services/billing'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import type { AccountPublic } from '@/types'
|
import type { AccountPublic } from '@/types'
|
||||||
|
|
||||||
@@ -70,6 +71,12 @@ export default function Accounts() {
|
|||||||
}
|
}
|
||||||
}, [accountIndustries, editingId, form])
|
}, [accountIndustries, editingId, form])
|
||||||
|
|
||||||
|
// 获取所有活跃计划(用于管理员切换)
|
||||||
|
const { data: plansData } = useQuery({
|
||||||
|
queryKey: ['billing-plans'],
|
||||||
|
queryFn: ({ signal }) => billingService.listPlans(signal),
|
||||||
|
})
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) =>
|
mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) =>
|
||||||
accountService.update(id, data),
|
accountService.update(id, data),
|
||||||
@@ -101,6 +108,14 @@ export default function Accounts() {
|
|||||||
onError: (err: Error) => message.error(err.message || '行业授权更新失败'),
|
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>[] = [
|
const columns: ProColumns<AccountPublic>[] = [
|
||||||
{ title: '用户名', dataIndex: 'username', width: 120, tooltip: '搜索用户名、邮箱或显示名' },
|
{ title: '用户名', dataIndex: 'username', width: 120, tooltip: '搜索用户名、邮箱或显示名' },
|
||||||
{ title: '显示名', dataIndex: 'display_name', width: 120, hideInSearch: true },
|
{ title: '显示名', dataIndex: 'display_name', width: 120, hideInSearch: true },
|
||||||
@@ -186,7 +201,7 @@ export default function Accounts() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 更新基础信息
|
// 更新基础信息
|
||||||
const { industry_ids, ...accountData } = values
|
const { industry_ids, plan_id, ...accountData } = values
|
||||||
await updateMutation.mutateAsync({ id: editingId, data: accountData })
|
await updateMutation.mutateAsync({ id: editingId, data: accountData })
|
||||||
|
|
||||||
// 更新行业授权(如果变更了)
|
// 更新行业授权(如果变更了)
|
||||||
@@ -201,6 +216,11 @@ export default function Accounts() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['account-industries'] })
|
queryClient.invalidateQueries({ queryKey: ['account-industries'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 切换订阅计划(如果选择了新计划)
|
||||||
|
if (plan_id) {
|
||||||
|
await switchPlanMutation.mutateAsync({ accountId: editingId, planId: plan_id })
|
||||||
|
}
|
||||||
|
|
||||||
handleClose()
|
handleClose()
|
||||||
} catch {
|
} catch {
|
||||||
// Errors handled by mutation onError callbacks
|
// Errors handled by mutation onError callbacks
|
||||||
@@ -218,6 +238,11 @@ export default function Accounts() {
|
|||||||
label: `${item.icon} ${item.name}`,
|
label: `${item.icon} ${item.name}`,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const planOptions = (plansData || []).map((plan) => ({
|
||||||
|
value: plan.id,
|
||||||
|
label: `${plan.display_name} (¥${(plan.price_cents / 100).toFixed(0)}/月)`,
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader title="账号管理" description="管理系统用户账号、角色、权限与行业授权" />
|
<PageHeader title="账号管理" description="管理系统用户账号、角色、权限与行业授权" />
|
||||||
@@ -256,7 +281,7 @@ export default function Accounts() {
|
|||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
onOk={handleSave}
|
onOk={handleSave}
|
||||||
onCancel={handleClose}
|
onCancel={handleClose}
|
||||||
confirmLoading={updateMutation.isPending || setIndustriesMutation.isPending}
|
confirmLoading={updateMutation.isPending || setIndustriesMutation.isPending || switchPlanMutation.isPending}
|
||||||
width={560}
|
width={560}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" className="mt-4">
|
<Form form={form} layout="vertical" className="mt-4">
|
||||||
@@ -280,6 +305,21 @@ export default function Accounts() {
|
|||||||
]} />
|
]} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Divider>订阅计划</Divider>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="plan_id"
|
||||||
|
label="切换计划"
|
||||||
|
extra="选择新计划后保存将立即切换。留空则不修改当前计划。"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="不修改当前计划"
|
||||||
|
options={planOptions}
|
||||||
|
loading={!plansData}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Divider>行业授权</Divider>
|
<Divider>行业授权</Divider>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
@@ -90,4 +90,9 @@ export const billingService = {
|
|||||||
getPaymentStatus: (id: string, signal?: AbortSignal) =>
|
getPaymentStatus: (id: string, signal?: AbortSignal) =>
|
||||||
request.get<PaymentStatus>(`/billing/payments/${id}`, withSignal({}, signal))
|
request.get<PaymentStatus>(`/billing/payments/${id}`, withSignal({}, signal))
|
||||||
.then((r) => r.data),
|
.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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use axum::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::auth::types::AuthContext;
|
use crate::auth::types::AuthContext;
|
||||||
|
use crate::auth::handlers::{log_operation, check_permission};
|
||||||
use crate::error::{SaasError, SaasResult};
|
use crate::error::{SaasError, SaasResult};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use super::service;
|
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 — 创建支付订单
|
/// POST /api/v1/billing/payments — 创建支付订单
|
||||||
pub async fn create_payment(
|
pub async fn create_payment(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ pub mod handlers;
|
|||||||
pub mod payment;
|
pub mod payment;
|
||||||
pub mod invoice_pdf;
|
pub mod invoice_pdf;
|
||||||
|
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post, put};
|
||||||
|
|
||||||
/// 全部计费路由(用于 main.rs 一次性挂载)
|
/// 全部计费路由(用于 main.rs 一次性挂载)
|
||||||
pub fn routes() -> axum::Router<crate::state::AppState> {
|
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", get(handlers::mock_pay_page))
|
||||||
.route("/api/v1/billing/mock-pay/confirm", post(handlers::mock_pay_confirm))
|
.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(())
|
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 表冗余列)
|
/// P1-7 修复: 从当前 Plan 读取限额(而非 stale 的 usage 表冗余列)
|
||||||
|
|||||||
@@ -159,3 +159,9 @@ pub struct PaymentResult {
|
|||||||
pub pay_url: String,
|
pub pay_url: String,
|
||||||
pub amount_cents: i32,
|
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>> {
|
) -> SaasResult<PaginatedResponse<IndustryListItem>> {
|
||||||
let (page, page_size, offset) = normalize_pagination(query.page, query.page_size);
|
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 status_param: Option<String> = query.status.clone();
|
||||||
let source_param: Option<String> = query.source.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() {
|
if status_param.is_some() {
|
||||||
where_parts.push(format!("status = ${}", param_idx));
|
count_params.push(format!("status = ${}", count_idx));
|
||||||
param_idx += 1;
|
count_idx += 1;
|
||||||
}
|
}
|
||||||
if source_param.is_some() {
|
if source_param.is_some() {
|
||||||
where_parts.push(format!("source = ${}", param_idx));
|
count_params.push(format!("source = ${}", count_idx));
|
||||||
param_idx += 1;
|
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 查询
|
// 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);
|
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) = status_param { count_q = count_q.bind(s); }
|
||||||
if let Some(ref s) = source_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, \
|
COALESCE(jsonb_array_length(keywords), 0) as keywords_count, \
|
||||||
created_at, updated_at \
|
created_at, updated_at \
|
||||||
FROM industries WHERE {} ORDER BY source, id LIMIT $1 OFFSET $2",
|
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)
|
let mut items_q = sqlx::query_as::<_, IndustryListItem>(&items_sql)
|
||||||
.bind(page_size as i64)
|
.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::scheduled_task::routes())
|
||||||
.merge(zclaw_saas::telemetry::routes())
|
.merge(zclaw_saas::telemetry::routes())
|
||||||
.merge(zclaw_saas::billing::routes())
|
.merge(zclaw_saas::billing::routes())
|
||||||
|
.merge(zclaw_saas::billing::admin_routes())
|
||||||
.merge(zclaw_saas::knowledge::routes())
|
.merge(zclaw_saas::knowledge::routes())
|
||||||
.merge(zclaw_saas::industry::routes())
|
.merge(zclaw_saas::industry::routes())
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(
|
||||||
|
|||||||
Reference in New Issue
Block a user