From a842f878126c3ddbfd6ceeb84269c9d7576fceea Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Mon, 18 May 2026 15:43:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(users):=20=E8=B6=85=E7=AE=A1+=E5=9B=A2?= =?UTF-8?q?=E7=AE=A1=E5=8F=AF=E6=94=B9=E7=94=A8=E6=88=B7=E5=90=8D=20?= =?UTF-8?q?=E2=80=94=20=E5=86=85=E8=81=94=E7=BC=96=E8=BE=91=20+=205=20?= =?UTF-8?q?=E6=AD=A5=E6=9D=83=E9=99=90=E7=9F=A9=E9=98=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - AdminAuditLog 新增 user_username_update 操作类型 (0015 migration) - admin_user_username_update_view (PATCH /admin/users//username, 仅超管, admin 账号不可改) - team_member_username_update_view (PATCH /team/members//username, 团管, 同 reset-password 5 步矩阵: 同团/拒自己/拒admin/拒主管/副管不改副管) - 长度按 UTF-8 字节计 3-20 字节 (≈ 3-20 英文字符 或 1-6 中文字符) 前端: - adminApi.updateUserUsername + teamApi.updateMemberUsername - UsersPage 用户名 cell 内联「改名」按钮 (admin 行隐藏) - TeamMembersPage 用户名 cell 内联「改名」按钮 (canEditUsernameFor 守卫) - 按现有 TeamsPage inline edit 模板 (inline-flex + whiteSpace:nowrap) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../0015_add_username_update_audit_action.py | 18 + backend/apps/accounts/models.py | 1 + backend/apps/generation/urls.py | 2 + backend/apps/generation/views.py | 101 ++++ docs/todo/用户名修改+观察者标记.md | 567 ++++++++++++++++++ web/src/lib/api.ts | 6 + web/src/pages/TeamMembersPage.tsx | 80 ++- web/src/pages/UsersPage.tsx | 74 ++- 8 files changed, 835 insertions(+), 14 deletions(-) create mode 100644 backend/apps/accounts/migrations/0015_add_username_update_audit_action.py create mode 100644 docs/todo/用户名修改+观察者标记.md diff --git a/backend/apps/accounts/migrations/0015_add_username_update_audit_action.py b/backend/apps/accounts/migrations/0015_add_username_update_audit_action.py new file mode 100644 index 0000000..efabd1d --- /dev/null +++ b/backend/apps/accounts/migrations/0015_add_username_update_audit_action.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.29 on 2026-05-18 15:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0014_set_existing_admins_as_owners'), + ] + + operations = [ + migrations.AlterField( + model_name='adminauditlog', + name='action', + field=models.CharField(choices=[('team_create', '创建团队'), ('team_update', '更新团队'), ('team_topup', '团队充值'), ('team_set_pool', '设置团队额度池'), ('team_create_admin', '创建团队管理员'), ('user_create', '创建用户'), ('user_quota_update', '更新用户额度'), ('user_status_toggle', '切换用户状态'), ('settings_update', '更新系统设置'), ('member_create', '创建团队成员'), ('member_quota_update', '更新成员额度'), ('member_status_toggle', '切换成员状态'), ('user_password_reset', '重置用户密码'), ('user_username_update', '修改用户名')], max_length=30, verbose_name='操作类型'), + ), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index ffedd24..43e4fa3 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -96,6 +96,7 @@ class AdminAuditLog(models.Model): ('member_quota_update', '更新成员额度'), ('member_status_toggle', '切换成员状态'), ('user_password_reset', '重置用户密码'), + ('user_username_update', '修改用户名'), ] operator = models.ForeignKey( diff --git a/backend/apps/generation/urls.py b/backend/apps/generation/urls.py index 8a5ca45..df6a67d 100644 --- a/backend/apps/generation/urls.py +++ b/backend/apps/generation/urls.py @@ -34,6 +34,7 @@ urlpatterns = [ path('admin/users//quota', views.admin_user_quota_view, name='admin_user_quota'), path('admin/users//status', views.admin_user_status_view, name='admin_user_status'), path('admin/users//reset-password', views.admin_reset_password_view, name='admin_reset_password'), + path('admin/users//username', views.admin_user_username_update_view, name='admin_user_username_update'), # ── Super Admin: Records, Settings & Audit Logs ── path('admin/records', views.admin_records_view, name='admin_records'), @@ -65,6 +66,7 @@ urlpatterns = [ path('team/members//status', views.team_member_status_view, name='team_member_status'), path('team/members//role', views.team_member_role_view, name='team_member_role'), path('team/members//reset-password', views.team_reset_member_password_view, name='team_reset_member_password'), + path('team/members//username', views.team_member_username_update_view, name='team_member_username_update'), # ── Team Admin: Consumption Records ── path('team/records', views.team_records_view, name='team_records'), diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 29d3e52..701ed37 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -1723,6 +1723,47 @@ def admin_reset_password_view(request, user_id): return Response({'message': f'已重置 {user.username} 的密码'}) +@api_view(['PATCH']) +@permission_classes([IsSuperAdmin]) +def admin_user_username_update_view(request, user_id): + """PATCH /api/v1/admin/users//username — 超管修改任意用户的用户名。""" + try: + user = User.objects.get(id=user_id) + except User.DoesNotExist: + return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND) + + # admin 账号保护:用户名不可修改(无论操作者是谁) + if user.username == 'admin': + return Response({'error': '不能修改超级管理员的用户名'}, status=status.HTTP_403_FORBIDDEN) + + new_username = (request.data.get('username') or '').strip() + # 长度按 UTF-8 字节计:ASCII 1 字节、中文 3 字节;3-20 字节 ≈ 3-20 个英文字符 或 ~1-6 个中文字符 + new_bytes = len(new_username.encode('utf-8')) + if not (3 <= new_bytes <= 20): + return Response({'error': '用户名长度需 3-20 个字符'}, status=status.HTTP_400_BAD_REQUEST) + if new_username == user.username: + return Response({'error': '新用户名与原用户名相同'}, status=status.HTTP_400_BAD_REQUEST) + if User.objects.filter(username=new_username).exclude(id=user.id).exists(): + return Response({'error': '该用户名已被占用'}, status=status.HTTP_400_BAD_REQUEST) + + from django.core.exceptions import ValidationError as DjangoValidationError + old_username = user.username + user.username = new_username + try: + user.full_clean(exclude=['password']) + except DjangoValidationError: + return Response({'error': '用户名包含非法字符'}, status=status.HTTP_400_BAD_REQUEST) + user.save(update_fields=['username']) + + log_admin_action( + request, 'user_username_update', 'user', + target_id=user.id, target_name=new_username, + before={'username': old_username}, + after={'username': new_username}, + ) + return Response({'user_id': user.id, 'username': user.username}) + + @api_view(['POST']) @permission_classes([IsSuperAdmin]) def admin_create_user_view(request): @@ -2743,6 +2784,66 @@ def team_reset_member_password_view(request, member_id): }) +@api_view(['PATCH']) +@permission_classes([IsTeamAdmin]) +def team_member_username_update_view(request, member_id): + """PATCH /api/v1/team/members//username — 团管修改本团队成员用户名。 + + 权限矩阵(同 team_reset_member_password_view): + - 主管 (is_team_owner=True): 可改同团队的副管 + 成员的用户名 + - 副管 (is_team_admin=True && !is_team_owner): 只能改同团队成员的用户名 + """ + team = request.user.team + if team is None: + return Response({'error': '当前用户没有团队'}, status=status.HTTP_400_BAD_REQUEST) + + try: + target = team.members.get(id=member_id) + except User.DoesNotExist: + return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND) + + operator = request.user + + # 防御性兜底 + if target.team_id != operator.team_id: + return Response({'error': '不在同一团队'}, status=status.HTTP_403_FORBIDDEN) + if target.id == operator.id: + return Response({'error': '不能修改自己的用户名'}, status=status.HTTP_400_BAD_REQUEST) + if target.username == 'admin': + return Response({'error': '不能修改超级管理员的用户名'}, status=status.HTTP_403_FORBIDDEN) + if target.is_team_owner: + return Response({'error': '不能修改主管理员的用户名'}, status=status.HTTP_403_FORBIDDEN) + if target.is_team_admin and not operator.is_team_owner: + return Response({'error': '只有主管理员能修改副管理员的用户名'}, status=status.HTTP_403_FORBIDDEN) + + new_username = (request.data.get('username') or '').strip() + # 长度按 UTF-8 字节计:ASCII 1 字节、中文 3 字节;3-20 字节 ≈ 3-20 个英文字符 或 ~1-6 个中文字符 + new_bytes = len(new_username.encode('utf-8')) + if not (3 <= new_bytes <= 20): + return Response({'error': '用户名长度需 3-20 个字符'}, status=status.HTTP_400_BAD_REQUEST) + if new_username == target.username: + return Response({'error': '新用户名与原用户名相同'}, status=status.HTTP_400_BAD_REQUEST) + if User.objects.filter(username=new_username).exclude(id=target.id).exists(): + return Response({'error': '该用户名已被占用'}, status=status.HTTP_400_BAD_REQUEST) + + from django.core.exceptions import ValidationError as DjangoValidationError + old_username = target.username + target.username = new_username + try: + target.full_clean(exclude=['password']) + except DjangoValidationError: + return Response({'error': '用户名包含非法字符'}, status=status.HTTP_400_BAD_REQUEST) + target.save(update_fields=['username']) + + log_admin_action( + request, 'user_username_update', 'user', + target_id=target.id, target_name=new_username, + before={'username': old_username}, + after={'username': new_username}, + ) + return Response({'user_id': target.id, 'username': target.username}) + + # ────────────────────────────────────────────── # Profile: User's own consumption data # ────────────────────────────────────────────── diff --git a/docs/todo/用户名修改+观察者标记.md b/docs/todo/用户名修改+观察者标记.md new file mode 100644 index 0000000..58ab6bd --- /dev/null +++ b/docs/todo/用户名修改+观察者标记.md @@ -0,0 +1,567 @@ +# 用户名修改 + 观察者标记 — 实施计划 + +> Plan agent 基于代码现状产出(不靠猜)。dev agent 可照着实施。 + +## 用户拍板的决策 + +| # | 决策 | +|---|------| +| 1 | 改用户名权限:超管+团管都能改、各管各的范围 | +| 2 | 观察者标记只能给团管打(普通成员先升团管) | +| 3 | 观察者视野:全部团队(含自己团队) | +| 4 | 观察者能下载(能看就能下) | + +--- + +## Phase 0 — 现状事实表(plan agent 读完代码后确认) + +### Backend + +| Item | Truth | +|---|---| +| accounts 最高 migration | `0014_set_existing_admins_as_owners.py` → 下一个 **0015** | +| `User.role` property | `'super_admin'` / `'team_admin'` / `'member'` (`accounts/models.py:74-80`) | +| `AdminAuditLog.ACTION_CHOICES` | 13 actions 末尾 `('user_password_reset', '重置用户密码')` (`accounts/models.py:84-99`) | +| `UserSerializer` fields | `id, username, email, is_staff, is_team_admin, is_team_owner, role, team_name, must_change_password` (`accounts/serializers.py:14`) | +| `admin_user_detail_view` | GET only (`views.py:1535`) | +| `team_member_detail_view` | GET only (`views.py:2494`) | +| **照抄 5 步权限矩阵** | `team_reset_member_password_view` at `views.py:2683-2743` | +| **admin 账号保护** | `admin_user_status_view:1675` (`if user.username == 'admin': return 403`) | +| Username validator | Django `UnicodeUsernameValidator` 允许 `[\w.@+-]` Unicode → 中文 OK | +| 三个资产 endpoint | `views.py:2912 / 2961 / 3001` 全用 `[IsSuperAdmin]` | +| `log_admin_action` 签名 | `(request, action, target_type, target_id=None, target_name='', before=None, after=None)` (`accounts/models.py:243`) | + +### Frontend + +| Item | Truth | +|---|---| +| 内联编辑模板 | `TeamsPage.tsx:445-491` — `` + 保存/取消按 `whiteSpace:'nowrap'`(**没有可复用 InlineEdit 组件,就地写**) | +| `User` type (`types/index.ts:89`) | 已有 `is_team_owner?: boolean`,需加 `is_observer` | +| `ProtectedRoute` props | 已有 `requireAdmin / requireTeamAdmin / requireTeamMember`,需加 `requireAdminOrObserver` | +| `AdminLayout` nav | hardcoded `navItems` at top | +| `TeamAdminLayout` | 4 项 dashboard/members/records/assets(团队的) | +| UsersPage row | L228-271;username at L237 (` +)} +``` + +**文件 13:`web/src/pages/AdminAssetsPage.tsx`** — 4 处 ¥ 条件渲染 + +```tsx +const user = useAuthStore((s) => s.user); +const hideMoney = user?.role !== 'super_admin'; // observer 团管 = 非超管 = 隐藏 + +// L158 总费用统计卡(整张条件渲染) +{!hideMoney && ( +
+
总费用
+
{formatCost(overview.total_seconds)}
+
+)} + +// L174 team badge +{!hideMoney && {formatCost(team.cost_consumed ?? team.seconds_consumed)}} + +// L195 member badge — 同 L174 + +// L238 no-team badge — 同 L174 +``` + +`VideoDetailModal` 不动(`assetVideoToTask` 已经不填 tokensConsumed/costAmount,¥ 行已经不渲染)。**在 `assetVideoToTask` 上方加注释**: + +```tsx +// 不传 tokensConsumed/costAmount — 观察者团管隐藏 ¥ 依赖此默认行为 +``` + +**文件 14:`web/src/pages/UsersPage.tsx`** — 行加切换按钮 + observer badge + +```tsx +{u.is_team_admin && u.team_id && ( + +)} + +// 用户名/团队 cell 后加 badge +{u.is_observer && ( + + 观察者 + +)} +``` + +### Phase B 验收 cases + +| # | Scenario | 期望 | +|---|---|---| +| B1 | 超管 PATCH 把 `tudou`(团管)`is_observer=true` | 200, DB 更新, audit log `user_observer_toggle` | +| B2 | 超管对普通成员设 | 400 `观察者标记只能给团队管理员` | +| B3 | 超管对 `admin` 自己设 | 400 `超级管理员无需设观察者` | +| B4 | `tudou`(is_observer=true)GET `/admin/assets/overview` | 200 全局数据 | +| B5 | `tudou`(is_observer=false)GET `/admin/assets/overview` | 403 | +| B6 | 普通成员 `bob` GET `/admin/assets/overview` | 403 | +| B7 | 观察者 `tudou` 登录后 `/auth/me` 返回 `is_observer: true` → sidebar 出现「全局资产」入口 | 视觉验证 | +| B8 | 观察者打开 `/admin/assets`:3 张统计卡少 1 张,所有 badge 仅剩「N 个视频」(**无 ¥**) | 视觉验证 | +| B9 | 观察者点视频详情:info bar 无 tokens/¥ | 视觉验证 | +| B10 | 观察者尝试访问 `/admin/users` | 被 `requireAdmin` 拒,自动 302 到 `/team/dashboard`(团管 fallback) | + +--- + +## 9 个关键风险点 + +1. **admin 账号在 team_member 那条线也得兜底拒**(已在 view 内加 `if target.username == 'admin'`) +2. **副管/主管判断顺序**:先 `is_team_owner` 再 `is_team_admin`(照抄 `team_reset_member_password_view:2717-2722`) +3. **MySQL 严格模式**:`is_observer` BooleanField default=False 无风险;但需 grep 验证无裸 `User(**data).save()` 用法 +4. **JWT token 不缓存 `is_observer`**,但**前端 `auth.user.is_observer` 是登录时拉的快照** → 观察者标记切换后那个用户必须重新登录或刷新 `/auth/me` 才能在自己客户端看到「全局资产」入口。UsersPage toast 已写「需该用户重新登录后生效」 +5. **`/admin/users` 等子路由的 race**:父路由 `requireAdminOrObserver`,子页面分别用 `requireAdmin` 拦截(观察者团管根本进不去子页面渲染) +6. **普通团管被 `requireAdmin` 拒** → 弹回 `/team/dashboard` 不是 `/app`(ProtectedRoute 智能 fallback) +7. **迁移编号**:A=0015, B=0016(已确认 accounts 当前最高 0014) +8. **观察者下载**:用户决策「能看就能下」→ VideoDetailModal 已有下载按钮保留不动 +9. **空白/前后空格**:已 `.strip()`;中文/特殊字符按 `UnicodeUsernameValidator` `full_clean()` 自动处理;不另加禁止 + +--- + +## 实施顺序(两个 commit,A 不依赖 B) + +``` +commit 1 (Phase A 修改用户名): +- backend: models.py choices, views.py 2 view, urls.py 2 行, migration 0015 +- frontend: api.ts 2 方法, UsersPage.tsx inline edit, TeamMembersPage.tsx inline edit +- 跑测试: cd backend && python manage.py check && python manage.py migrate + cd web && npm run build && npx vitest run +- 跑 curl A1-A8 8 项验收 + +commit 2 (Phase B 观察者): +- backend: models.py 字段+choice, migration 0016, serializers.py, permissions.py, + views.py 3 endpoint 权限改 + 1 新 view, urls.py 1 行 +- frontend: types/index.ts 3 处 is_observer, api.ts 1 方法, + ProtectedRoute.tsx 新 prop + 智能 fallback, App.tsx 路由分级守卫, + AdminLayout.tsx 角色过滤, TeamAdminLayout.tsx 加全局资产入口, + AdminAssetsPage.tsx 4 处 ¥ 条件渲染, UsersPage.tsx 切换按钮 + badge +- 跑测试: 同上 +- 跑 curl B1-B10 10 项验收 +``` diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 5cb0500..1e9735a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -256,6 +256,9 @@ export const adminApi = { resetUserPassword: (userId: number, newPassword: string) => api.post(`/admin/users/${userId}/reset-password`, { new_password: newPassword }), + updateUserUsername: (userId: number, username: string) => + api.patch<{ user_id: number; username: string }>(`/admin/users/${userId}/username`, { username }), + getRecords: (params: { page?: number; page_size?: number; @@ -383,6 +386,9 @@ export const teamApi = { resetMemberPassword: (memberId: number) => api.post<{ user_id: number; username: string; new_password: string; message: string }>(`/team/members/${memberId}/reset-password`), + updateMemberUsername: (memberId: number, username: string) => + api.patch<{ user_id: number; username: string }>(`/team/members/${memberId}/username`, { username }), + // Content Assets getAssetsOverview: () => api.get<{ diff --git a/web/src/pages/TeamMembersPage.tsx b/web/src/pages/TeamMembersPage.tsx index ee282d1..585b305 100644 --- a/web/src/pages/TeamMembersPage.tsx +++ b/web/src/pages/TeamMembersPage.tsx @@ -31,6 +31,10 @@ export function TeamMembersPage() { // Reset password result modal — 显示新生成的随机密码 + 复制按钮 const [resetResult, setResetResult] = useState<{ username: string; newPassword: string } | null>(null); + // Inline username edit + const [editingUsernameId, setEditingUsernameId] = useState(null); + const [editingUsernameValue, setEditingUsernameValue] = useState(''); + // 权限矩阵: // 主管(is_team_owner) → 可重置「副管 + 成员」(不可重置主管/自己) // 副管(is_team_admin) → 只能重置「成员」(不可重置副管/主管/自己) @@ -43,6 +47,40 @@ export function TeamMembersPage() { return true; }; + // 权限矩阵(用户名修改): 同 canResetPasswordFor 但额外拒绝 admin 账号 + const canEditUsernameFor = (m: TeamMember): boolean => { + if (!currentUser) return false; + if (m.id === currentUser.id) return false; + if (m.username === 'admin') return false; + if (m.is_team_owner) return false; + if (m.is_team_admin && !currentUser.is_team_owner) return false; + return true; + }; + + const startEditUsername = (m: TeamMember) => { + setEditingUsernameId(m.id); + setEditingUsernameValue(m.username); + }; + + const cancelEditUsername = () => { + setEditingUsernameId(null); + setEditingUsernameValue(''); + }; + + const handleSaveUsername = async (m: TeamMember) => { + const newName = editingUsernameValue.trim(); + if (!newName) { showToast('请输入用户名'); return; } + if (newName === m.username) { cancelEditUsername(); return; } + try { + await teamApi.updateMemberUsername(m.id, newName); + showToast(`已更新用户名为「${newName}」`); + cancelEditUsername(); + fetchMembers(); + } catch (e: any) { + showToast(e?.response?.data?.error || '操作失败'); + } + }; + const handleResetPassword = async (m: TeamMember) => { if (!window.confirm(`重置「${m.username}」的密码?\n成员下次登录需要修改新密码。`)) return; try { @@ -174,12 +212,42 @@ export function TeamMembersPage() { members.map((m) => ( - - {m.username} + {editingUsernameId === m.id ? ( + + setEditingUsernameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveUsername(m); + else if (e.key === 'Escape') cancelEditUsername(); + }} + autoFocus + style={{ width: 140, padding: '3px 6px', borderRadius: 4, border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: 13 }} + /> + + + + ) : ( + + + {m.username} + {canEditUsernameFor(m) && ( + + )} + + )} {m.is_team_owner ? ( diff --git a/web/src/pages/UsersPage.tsx b/web/src/pages/UsersPage.tsx index 5fae861..6a9e085 100644 --- a/web/src/pages/UsersPage.tsx +++ b/web/src/pages/UsersPage.tsx @@ -35,6 +35,10 @@ export function UsersPage() { const [resetPwValue, setResetPwValue] = useState(''); const [resetPwError, setResetPwError] = useState(''); + // Inline username edit + const [editingUsernameId, setEditingUsernameId] = useState(null); + const [editingUsernameValue, setEditingUsernameValue] = useState(''); + // Create user modal const [createOpen, setCreateOpen] = useState(false); const [newUsername, setNewUsername] = useState(''); @@ -142,6 +146,30 @@ export function UsersPage() { } }; + const startEditUsername = (u: AdminUser) => { + setEditingUsernameId(u.id); + setEditingUsernameValue(u.username); + }; + + const cancelEditUsername = () => { + setEditingUsernameId(null); + setEditingUsernameValue(''); + }; + + const handleSaveUsername = async (u: AdminUser) => { + const newName = editingUsernameValue.trim(); + if (!newName) { showToast('请输入用户名'); return; } + if (newName === u.username) { cancelEditUsername(); return; } + try { + await adminApi.updateUserUsername(u.id, newName); + showToast(`已更新用户名为「${newName}」`); + cancelEditUsername(); + fetchUsers(); + } catch (e: any) { + showToast(e.response?.data?.error || '操作失败'); + } + }; + const handleResetPassword = async () => { if (!resetPwUser) return; setResetPwError(''); @@ -228,14 +256,44 @@ export function UsersPage() { users.map((u) => ( - + {editingUsernameId === u.id ? ( + + setEditingUsernameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveUsername(u); + else if (e.key === 'Escape') cancelEditUsername(); + }} + autoFocus + style={{ width: 140, padding: '3px 6px', borderRadius: 4, border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: 13 }} + /> + + + + ) : ( + + + {u.username !== 'admin' && ( + + )} + + )} {u.team_name || '-'} {u.email}