feat: add user profile management and admin user deletion

This commit is contained in:
flym 2026-09-11 18:22:48 +08:00
parent 57c854a3f2
commit 5b9d74acb7
5 changed files with 269 additions and 12 deletions

View File

@ -7,7 +7,7 @@ import { PreviewNotice, StatusCard } from "@/components/ui";
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
type User = { email: string; username: string; role: "user" | "admin"; plan: "free" | "vip"; status: string; email_verified: boolean; phone_verified: boolean };
type User = { email: string | null; phone: string | null; username: string; role: "user" | "admin"; plan: "free" | "vip"; status: string; email_verified: boolean; phone_verified: boolean };
type Usage = { period_start: string; period_end: string; limit: number; adjustment: number; used: number; reserved: number; available: number; plan: string };
export function AccountPanel() {
@ -16,6 +16,7 @@ export function AccountPanel() {
const [usage, setUsage] = useState<Usage | null>(null);
const [error, setError] = useState("");
const [passwordMessage, setPasswordMessage] = useState("");
const [profileMessage, setProfileMessage] = useState("");
async function load() {
const csrfResponse = await fetch(`${apiBase}/auth/csrf`, { credentials: "include" });
@ -40,21 +41,29 @@ export function AccountPanel() {
async function changePassword(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
setPasswordMessage("");
const data = new FormData(form);
const response = await fetch(`${apiBase}/auth/password/change`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf }, body: JSON.stringify({ current_password: data.get("current_password"), new_password: data.get("new_password") }) });
const body = await response.json().catch(() => ({}));
if (!response.ok) setPasswordMessage(body.error?.message ?? "密码修改失败");
if (!response.ok) setPasswordMessage(body.error?.message ?? body.detail?.message ?? "密码修改失败");
else { setPasswordMessage("密码已修改,其他会话已撤销"); form.reset(); }
}
async function updateProfile(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
const response = await fetch(`${apiBase}/auth/profile`, { method: "PATCH", credentials: "include", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf }, body: JSON.stringify({ username: data.get("username"), email: data.get("email") || null, phone: data.get("phone") || null }) });
const body = await response.json().catch(() => ({}));
if (!response.ok) setProfileMessage(body.error?.message ?? body.detail?.message ?? "资料保存失败");
else { setUser(body); setProfileMessage("资料已保存;如修改邮箱或手机号,请重新完成验证"); }
}
if (error) return <div className="mt-8"><PreviewNotice>{error}<Link className="text-cyan underline" href="/login"></Link></PreviewNotice></div>;
if (!user || !usage) return <div className="mt-8"><StatusCard title="正在加载账户" description="正在从业务 API 读取当前用户和额度。" tone="info" /></div>;
return <>
<div className="mt-8 grid gap-5 md:grid-cols-2 lg:grid-cols-4"><StatusCard title="账户状态" description={user.status === "active" ? "正常" : "已停用"} tone="success" /><StatusCard title="会员计划" description={usage.plan.toUpperCase()} /><StatusCard title="可用额度" description={`${usage.available.toLocaleString()} 字符`} tone="info" /><StatusCard title="验证状态" description={`邮箱 ${user.email_verified ? "已验证" : "未验证"} · 手机 ${user.phone_verified ? "已验证" : "未验证"}`} tone="warning" /></div>
<div className="mt-8 grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
<section className="rounded-2xl border border-line bg-panel/60 p-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Profile</p><h2 className="mt-3 text-xl font-semibold"></h2><dl className="mt-6 space-y-5 text-sm"><div className="flex justify-between gap-4 border-b border-line pb-4"><dt className="text-muted"></dt><dd className="text-copy">{user.username}</dd></div><div className="flex justify-between gap-4 border-b border-line pb-4"><dt className="text-muted"></dt><dd className="text-copy">{user.email}</dd></div><div className="flex justify-between gap-4 border-b border-line pb-4"><dt className="text-muted"></dt><dd className="text-copy">{user.role}</dd></div>{user.role === "admin" ? <><div className="flex justify-between gap-4 border-b border-line pb-4"><dt className="text-muted"></dt><dd><Link className="text-cyan hover:text-copy" href="/admin"></Link></dd></div><div className="flex justify-between gap-4 border-b border-line pb-4"><dt className="text-muted"></dt><dd><a className="text-cyan hover:text-copy" href="/cms/admin"> CMS</a></dd></div></> : null}<div className="flex justify-between gap-4"><dt className="text-muted"></dt><dd className="text-right text-subtle">{new Date(usage.period_start).toLocaleDateString()} {new Date(usage.period_end).toLocaleDateString()}</dd></div></dl><button className="button-ghost mt-7 w-full" onClick={logout} type="button">退 <ArrowRight /></button></section>
<section className="rounded-2xl border border-line bg-panel/60 p-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Profile</p><h2 className="mt-3 text-xl font-semibold"></h2><form className="mt-6 space-y-4" onSubmit={updateProfile}><div><label className="field-label" htmlFor="profile-username"></label><input className="field-input" id="profile-username" name="username" required defaultValue={user.username} /></div><div><label className="field-label" htmlFor="profile-email"></label><input className="field-input" id="profile-email" name="email" type="email" defaultValue={user.email ?? ""} /></div><div><label className="field-label" htmlFor="profile-phone"></label><input className="field-input" id="profile-phone" name="phone" defaultValue={user.phone ?? ""} /></div>{profileMessage ? <p className="text-sm text-muted" role="status">{profileMessage}</p> : null}<button className="button-secondary w-full" type="submit"></button></form>{user.role === "admin" ? <div className="mt-6 space-y-4 border-t border-line pt-5"><div className="flex justify-between gap-4 text-sm"><dt className="text-muted"></dt><dd><Link className="text-cyan hover:text-copy" href="/admin"></Link></dd></div><div className="flex justify-between gap-4 text-sm"><dt className="text-muted"></dt><dd><a className="text-cyan hover:text-copy" href="/cms/admin"> CMS</a></dd></div></div> : null}<button className="button-ghost mt-7 w-full" onClick={logout} type="button">退 <ArrowRight /></button></section>
<div className="space-y-6"><section className="rounded-2xl border border-line bg-panel/60 p-6"><div className="flex items-center justify-between gap-4"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Usage</p><h2 className="mt-3 text-xl font-semibold"></h2></div><Link className="text-sm text-cyan hover:text-copy" href="/tts"></Link></div><div className="mt-6 grid gap-4 sm:grid-cols-3"><StatusCard title="周期上限" description={usage.limit.toLocaleString()} /><StatusCard title="已使用" description={usage.used.toLocaleString()} /><StatusCard title="冻结" description={usage.reserved.toLocaleString()} /></div></section><section className="rounded-2xl border border-line bg-panel/60 p-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Security</p><h2 className="mt-3 text-xl font-semibold"></h2><form className="mt-5 grid gap-4 sm:grid-cols-2" onSubmit={changePassword}><div><label className="field-label" htmlFor="current-password"></label><input className="field-input" id="current-password" name="current_password" required type="password" /></div><div><label className="field-label" htmlFor="new-password"></label><input className="field-input" id="new-password" minLength={8} name="new_password" required type="password" /></div>{passwordMessage ? <p className="sm:col-span-2 text-sm text-muted" role="status">{passwordMessage}</p> : null}<button className="button-secondary sm:col-span-2" type="submit"></button></form></section></div>
</div>
</>;

View File

@ -1,15 +1,18 @@
"use client";
import { useEffect, useState } from "react";
import { FormEvent, useEffect, useState } from "react";
import {
adjustQuota,
changeUserStatus,
createUser,
deleteUser,
fetchCsrf,
grantMembership,
getUserDetail,
getUserQuota,
listUsers,
revokeMembership,
updateUserProfile,
type AdminUser,
type AdminUserDetail,
} from "@/lib/admin";
@ -29,6 +32,7 @@ export function UsersPanel() {
const [error, setError] = useState("");
const [selected, setSelected] = useState<AdminUserDetail | null>(null);
const [notice, setNotice] = useState("");
const [showCreate, setShowCreate] = useState(false);
async function load(pageNum = page) {
setLoading(true);
@ -69,6 +73,9 @@ export function UsersPanel() {
<StatusCard title="总页数" description={`${pages}`} />
</div>
<button className="button-secondary" onClick={() => setShowCreate((value) => !value)} type="button">{showCreate ? "取消添加" : "手动添加用户"}</button>
{showCreate ? <CreateUserPanel onCreated={() => { setShowCreate(false); setNotice("用户已创建"); void load(1); }} /> : null}
<div className="rounded-2xl border border-line bg-panel/50">
<div className="grid gap-3 border-b border-line p-5 sm:grid-cols-[1fr_1fr_1fr_auto_auto]">
<input aria-label="按邮箱筛选" className="field-input" onChange={(e) => setEmail(e.target.value)} placeholder="按邮箱筛选" value={email} />
@ -109,8 +116,8 @@ export function UsersPanel() {
rows.map((row) => (
<tr className="hover:bg-panel/40" key={row.id}>
<td className="px-5 py-3">
<p className="font-semibold text-copy">{row.email}</p>
<p className="text-xs text-subtle">{row.phone ?? "无手机号"}</p>
<p className="font-semibold text-copy">{row.username}</p>
<p className="text-xs text-subtle">{row.email ?? "无邮箱"} · {row.phone ?? "无手机号"}</p>
</td>
<td className="px-5 py-3 text-cyan">{row.effective_plan.toUpperCase()}</td>
<td className="px-5 py-3">{row.role}</td>
@ -156,6 +163,11 @@ function UserDetailPanel({ detail, onRefresh, setNotice, onClose }: { detail: Ad
const [amount, setAmount] = useState("100");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [username, setUsername] = useState(detail.user.username);
const [email, setEmail] = useState(detail.user.email ?? "");
const [phone, setPhone] = useState(detail.user.phone ?? "");
const [emailVerified, setEmailVerified] = useState(detail.user.email_verified);
const [phoneVerified, setPhoneVerified] = useState(detail.user.phone_verified);
useEffect(() => {
void fetchCsrf().then(setCsrf);
@ -197,11 +209,24 @@ function UserDetailPanel({ detail, onRefresh, setNotice, onClose }: { detail: Ad
return run(() => adjustQuota(detail.user.id, amt, reason, `admin-${Date.now()}-${Math.random()}`, csrf), "额度已调整");
}
function saveProfile() {
if (!username.trim()) { setMessage("用户名不能为空"); return; }
if (!email.trim() && !phone.trim()) { setMessage("邮箱或手机号至少填写一项"); return; }
return run(() => updateUserProfile(detail.user.id, { username: username.trim(), email: email.trim() || null, phone: phone.trim() || null, email_verified: emailVerified, phone_verified: phoneVerified, reason: reason.trim() }, csrf), "用户信息已保存");
}
async function removeUser() {
if (!reason.trim()) { setMessage("请填写原因"); return; }
if (!window.confirm("确定删除该用户?删除后不可恢复。")) return;
setBusy(true);
try { await deleteUser(detail.user.id, reason, csrf); setNotice("用户已删除"); onClose(); } catch (e) { setMessage(e instanceof Error ? e.message : "删除失败"); } finally { setBusy(false); }
}
return (
<div className="rounded-2xl border border-line bg-panel/60 p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-xl font-semibold">{detail.user.email}</h3>
<h3 className="text-xl font-semibold">{detail.user.username}</h3>
<p className="mt-1 text-sm text-subtle"> Plan{detail.effective_plan.toUpperCase()} · {detail.user.status === "active" ? "正常" : "已禁用"}</p>
</div>
<button className="button-ghost" onClick={onClose} type="button"></button>
@ -251,6 +276,20 @@ function UserDetailPanel({ detail, onRefresh, setNotice, onClose }: { detail: Ad
</div>
</div>
<div className="mt-6 rounded-xl border border-line bg-panel/40 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-subtle"></p>
<div className="mt-4 grid gap-4 md:grid-cols-3">
<div><label className="field-label" htmlFor="admin-username"></label><input className="field-input" id="admin-username" onChange={(e) => setUsername(e.target.value)} value={username} /></div>
<div><label className="field-label" htmlFor="admin-email"></label><input className="field-input" id="admin-email" onChange={(e) => setEmail(e.target.value)} type="email" value={email} /></div>
<div><label className="field-label" htmlFor="admin-phone"></label><input className="field-input" id="admin-phone" onChange={(e) => setPhone(e.target.value)} value={phone} /></div>
</div>
<div className="mt-4 flex flex-wrap gap-5 text-sm">
<label className="flex items-center gap-2"><input checked={emailVerified} onChange={(e) => setEmailVerified(e.target.checked)} type="checkbox" /></label>
<label className="flex items-center gap-2"><input checked={phoneVerified} onChange={(e) => setPhoneVerified(e.target.checked)} type="checkbox" /></label>
</div>
<button className="button-secondary mt-4 disabled:opacity-50" disabled={busy || !csrf} onClick={() => void saveProfile()} type="button"></button>
</div>
<div className="mt-6 space-y-4">
<div><label className="field-label" htmlFor="admin-reason"></label><textarea className="field-input" id="admin-reason" onChange={(e) => setReason(e.target.value)} placeholder="例如:运营调整 / 违规禁用" rows={2} value={reason} /></div>
{message ? <p className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning" role="status">{message}</p> : null}
@ -265,11 +304,31 @@ function UserDetailPanel({ detail, onRefresh, setNotice, onClose }: { detail: Ad
<button className="button-ghost disabled:opacity-50" disabled={busy || !csrf} onClick={() => void doRevoke()} type="button"> VIP</button>
<div className="flex gap-2">
<input aria-label="额度调整量" className="field-input w-24" onChange={(e) => setAmount(e.target.value)} value={amount} />
<button className="button-secondary disabled:opacity-50" disabled={busy || !csrf} onClick={() => void doAdjust()} type="button"></button>
</div>
<button className="button-secondary disabled:opacity-50" disabled={busy || !csrf} onClick={() => void doAdjust()} type="button"></button>
<button className="button-ghost text-danger disabled:opacity-50" disabled={busy || !csrf} onClick={() => void removeUser()} type="button"></button>
</div>
</div>
<p className="text-xs text-subtle">使</p>
</div>
</div>
);
}
function CreateUserPanel({ onCreated }: { onCreated: () => void }) {
const [csrf, setCsrf] = useState("");
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => { void fetchCsrf().then(setCsrf); }, []);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setBusy(true); setMessage("");
try {
await createUser({ username: String(data.get("username") ?? ""), email: String(data.get("email") ?? "").trim() || null, phone: String(data.get("phone") ?? "").trim() || null, password: String(data.get("password") ?? ""), email_verified: data.get("email_verified") === "on", phone_verified: data.get("phone_verified") === "on", reason: String(data.get("reason") ?? "") }, csrf);
onCreated();
} catch (e) { setMessage(e instanceof Error ? e.message : "创建失败"); } finally { setBusy(false); }
}
return <form className="rounded-2xl border border-line bg-panel/50 p-5" onSubmit={submit}><p className="font-semibold"></p><div className="mt-4 grid gap-4 md:grid-cols-3"><input className="field-input" name="username" placeholder="用户名" required /><input className="field-input" name="email" placeholder="邮箱(可选)" type="email" /><input className="field-input" name="phone" placeholder="手机号(可选)" /><input className="field-input" minLength={8} name="password" placeholder="初始密码" required type="password" /><input className="field-input" name="reason" placeholder="创建原因(必填)" required /></div><div className="mt-4 flex flex-wrap gap-5 text-sm"><label className="flex items-center gap-2"><input name="email_verified" type="checkbox" /></label><label className="flex items-center gap-2"><input name="phone_verified" type="checkbox" /></label></div>{message ? <p className="mt-3 text-sm text-danger" role="status">{message}</p> : null}<button className="button-secondary mt-4 disabled:opacity-50" disabled={busy || !csrf} type="submit"></button></form>;
}

View File

@ -4,7 +4,8 @@ const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
export type AdminUser = {
id: string;
email: string;
username: string;
email: string | null;
phone: string | null;
role: "user" | "admin";
plan: string;
@ -69,6 +70,18 @@ export async function getUserDetail(id: string): Promise<AdminUserDetail> {
return request(`/admin/users/${id}`);
}
export async function createUser(payload: { username: string; email: string | null; phone: string | null; password: string; email_verified: boolean; phone_verified: boolean; reason: string }, csrf: string) {
return request(`/admin/users`, { method: "POST", headers: { "X-CSRF-Token": csrf }, body: JSON.stringify(payload) });
}
export async function updateUserProfile(id: string, payload: { username: string; email: string | null; phone: string | null; email_verified: boolean; phone_verified: boolean; reason: string }, csrf: string) {
return request(`/admin/users/${id}`, { method: "PATCH", headers: { "X-CSRF-Token": csrf }, body: JSON.stringify(payload) });
}
export async function deleteUser(id: string, reason: string, csrf: string) {
return request(`/admin/users/${id}`, { method: "DELETE", headers: { "X-CSRF-Token": csrf }, body: JSON.stringify({ reason }) });
}
export async function changeUserStatus(id: string, status: "active" | "disabled", reason: string, csrf: string): Promise<{ id: string; status: string; sessions_revoked: number }> {
return request(`/admin/users/${id}/status`, { method: "PATCH", headers: { "X-CSRF-Token": csrf }, body: JSON.stringify({ status, reason }) });
}

View File

@ -22,6 +22,8 @@ from psycopg.types.json import Json
from .config import settings
from .db import get_connection
from .schemas import (
AdminUserCreateRequest,
AdminUserUpdateRequest,
AuthResponse,
LoginRequest,
MembershipRequest,
@ -38,6 +40,7 @@ from .schemas import (
TtsTaskRequest,
TtsTaskPublic,
TtsVoicePublic,
UserProfileUpdateRequest,
normalize_email,
)
from .security import (
@ -490,6 +493,29 @@ def change_password(payload: PasswordChangeRequest, request: Request, connection
return {"status": "ok"}
@app.patch("/api/v1/auth/profile", dependencies=[Depends(require_csrf)])
def update_profile(payload: UserProfileUpdateRequest, connection: Connection = Depends(get_connection), user: dict = Depends(current_user)):
email = normalize_email(str(payload.email)) if payload.email else None
phone = payload.phone
username = payload.username.strip().lower()
try:
updated = connection.execute(
"""
UPDATE users SET username = %s, email = %s, phone = %s,
email_verified = CASE WHEN email IS DISTINCT FROM %s THEN false ELSE email_verified END,
phone_verified = CASE WHEN phone IS DISTINCT FROM %s THEN false ELSE phone_verified END,
updated_at = now()
WHERE id = %s RETURNING *
""",
(username, email, phone, email, phone, user["id"]),
).fetchone()
connection.commit()
except psycopg.errors.UniqueViolation:
connection.rollback()
raise error("PROFILE_UPDATE_CONFLICT", "用户名、邮箱或手机号已被占用", 409)
return user_public(connection, updated)
@app.get("/api/v1/account/usage")
def account_usage(user: dict = Depends(current_user), connection: Connection = Depends(get_connection)):
quota = ensure_quota(connection, user["id"], effective_plan(connection, user))
@ -677,7 +703,7 @@ def download_tts_audio(task_id: UUID, user: dict = Depends(current_user), connec
return audio_response(task_id, user, connection, True)
USER_COLUMNS = "id, email, phone, role, plan, status, email_verified, phone_verified, created_at, last_login_at, updated_at"
USER_COLUMNS = "id, username, email, phone, role, plan, status, email_verified, phone_verified, created_at, last_login_at, updated_at"
def quota_view_for(quota: dict) -> dict[str, Any]:
@ -757,6 +783,60 @@ def admin_user_detail(user_id: UUID, user: dict = Depends(require_admin), connec
}
@app.post("/api/v1/admin/users", dependencies=[Depends(require_csrf)])
def admin_create_user(payload: AdminUserCreateRequest, connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
email = normalize_email(str(payload.email)) if payload.email else None
phone = payload.phone
username = payload.username.strip().lower()
try:
created = connection.execute(
"""
INSERT INTO users(username, email, phone, password_hash, email_verified, phone_verified)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, username, email, phone, role, plan, status, email_verified, phone_verified, created_at, last_login_at, updated_at
""",
(username, email, phone, hash_password(payload.password), payload.email_verified, payload.phone_verified),
).fetchone()
connection.execute(
"INSERT INTO admin_audit_logs(actor_id, action, target_type, target_id, before_value, after_value, reason) VALUES (%s, 'user_create', 'user', %s, %s, %s, %s)",
(actor["id"], created["id"], Json({}), Json({"username": username, "email": email, "phone": phone, "email_verified": payload.email_verified, "phone_verified": payload.phone_verified}), payload.reason),
)
connection.commit()
except psycopg.errors.UniqueViolation:
connection.rollback()
raise error("USER_CREATE_CONFLICT", "用户名、邮箱或手机号已被占用", 409)
return dict(created)
@app.patch("/api/v1/admin/users/{user_id}", dependencies=[Depends(require_csrf)])
def admin_update_user(user_id: UUID, payload: AdminUserUpdateRequest, connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
target = connection.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone()
if not target:
raise error("NOT_FOUND", "用户不存在", 404)
email = normalize_email(str(payload.email)) if payload.email else None
phone = payload.phone
username = payload.username.strip().lower()
try:
updated = connection.execute(
"""
UPDATE users
SET username = %s, email = %s, phone = %s, email_verified = %s, phone_verified = %s, updated_at = now()
WHERE id = %s
RETURNING id, username, email, phone, role, plan, status, email_verified, phone_verified, created_at, last_login_at, updated_at
""",
(username, email, phone, payload.email_verified, payload.phone_verified, user_id),
).fetchone()
connection.execute(
"INSERT INTO admin_audit_logs(actor_id, action, target_type, target_id, before_value, after_value, reason) VALUES (%s, 'user_update', 'user', %s, %s, %s, %s)",
(actor["id"], user_id, Json({"username": target["username"], "email": target["email"], "phone": target["phone"], "email_verified": target["email_verified"], "phone_verified": target["phone_verified"]}), Json({"username": username, "email": email, "phone": phone, "email_verified": payload.email_verified, "phone_verified": payload.phone_verified}), payload.reason),
)
connection.commit()
except psycopg.errors.UniqueViolation:
connection.rollback()
raise error("USER_UPDATE_CONFLICT", "用户名、邮箱或手机号已被占用", 409)
return dict(updated)
@app.patch("/api/v1/admin/users/{user_id}/status", dependencies=[Depends(require_csrf)])
def admin_status(user_id: UUID, payload: StatusRequest, connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
target = connection.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone()
@ -777,6 +857,23 @@ def admin_status(user_id: UUID, payload: StatusRequest, connection: Connection =
return {"id": updated["id"], "status": updated["status"], "sessions_revoked": revoked_count}
@app.delete("/api/v1/admin/users/{user_id}", dependencies=[Depends(require_csrf)])
def admin_delete_user(user_id: UUID, payload: MembershipRevokeRequest, connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
target = connection.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone()
if not target:
raise error("NOT_FOUND", "用户不存在", 404)
if target["id"] == actor["id"]:
raise error("SELF_DELETE_FORBIDDEN", "不能删除当前登录的管理员账号", 409)
if target["role"] == "admin":
count = connection.execute("SELECT count(*) FROM users WHERE role = 'admin' AND status = 'active'").fetchone()["count"]
if count <= 1 and target["status"] == "active":
raise error("LAST_ADMIN_PROTECTED", "不能删除最后一个可用管理员", 409)
connection.execute("INSERT INTO admin_audit_logs(actor_id, action, target_type, target_id, before_value, after_value, reason) VALUES (%s, 'user_delete', 'user', %s, %s, %s, %s)", (actor["id"], user_id, Json({"username": target["username"], "email": target["email"], "phone": target["phone"], "role": target["role"]}), Json({"deleted": True}), payload.reason))
connection.execute("DELETE FROM users WHERE id = %s", (user_id,))
connection.commit()
return {"id": user_id, "status": "deleted"}
@app.get("/api/v1/admin/users/{user_id}/membership")
def admin_membership_view(user_id: UUID, user: dict = Depends(require_admin), connection: Connection = Depends(get_connection)):
target = connection.execute("SELECT id, role, plan FROM users WHERE id = %s", (user_id,)).fetchone()

View File

@ -49,6 +49,85 @@ class PasswordChangeRequest(BaseModel):
new_password: str = Field(min_length=8, max_length=128)
class AdminUserCreateRequest(BaseModel):
username: str = Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_\-]+$")
email: EmailStr | None = None
phone: str | None = Field(default=None, min_length=11, max_length=20)
password: str = Field(min_length=8, max_length=128)
email_verified: bool = False
phone_verified: bool = False
reason: str = Field(min_length=1, max_length=500)
@field_validator("phone")
@classmethod
def normalize_phone(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().replace(" ", "").replace("-", "")
if normalized.startswith("+86"):
normalized = normalized[3:]
if not normalized.isdigit() or len(normalized) != 11 or not normalized.startswith("1"):
raise ValueError("请输入有效的中国大陆手机号")
return normalized
@model_validator(mode="after")
def validate_contact(self) -> "AdminUserCreateRequest":
if not self.email and not self.phone:
raise ValueError("邮箱或手机号至少填写一项")
return self
class AdminUserUpdateRequest(BaseModel):
username: str = Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_\-]+$")
email: EmailStr | None = None
phone: str | None = Field(default=None, min_length=11, max_length=20)
email_verified: bool = False
phone_verified: bool = False
reason: str = Field(min_length=1, max_length=500)
@field_validator("phone")
@classmethod
def normalize_phone(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().replace(" ", "").replace("-", "")
if normalized.startswith("+86"):
normalized = normalized[3:]
if not normalized.isdigit() or len(normalized) != 11 or not normalized.startswith("1"):
raise ValueError("请输入有效的中国大陆手机号")
return normalized
@model_validator(mode="after")
def validate_contact(self) -> "AdminUserUpdateRequest":
if not self.email and not self.phone:
raise ValueError("邮箱或手机号至少填写一项")
return self
class UserProfileUpdateRequest(BaseModel):
username: str = Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_\-]+$")
email: EmailStr | None = None
phone: str | None = Field(default=None, min_length=11, max_length=20)
@field_validator("phone")
@classmethod
def normalize_phone(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().replace(" ", "").replace("-", "")
if normalized.startswith("+86"):
normalized = normalized[3:]
if not normalized.isdigit() or len(normalized) != 11 or not normalized.startswith("1"):
raise ValueError("请输入有效的中国大陆手机号")
return normalized
@model_validator(mode="after")
def validate_contact(self) -> "UserProfileUpdateRequest":
if not self.email and not self.phone:
raise ValueError("邮箱或手机号至少填写一项")
return self
class UserPublic(BaseModel):
id: UUID
username: str