From 253571730a8f68a036ad79506829f0cae7ca3523 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 4 Oct 2026 15:21:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(account):=20=E8=A1=A5=E4=B8=8A=E6=94=B9?= =?UTF-8?q?=E5=A4=87=E6=B3=A8=E6=8E=A5=E5=8F=A3=EF=BC=8Cremark=5Fchanged?= =?UTF-8?q?=20=E4=BA=8B=E4=BB=B6=E4=BB=8E=E6=AD=A4=E6=9C=89=E5=8F=91?= =?UTF-8?q?=E5=B0=84=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `remark_changed` 此前是「定义了事件类型、但压根没有改备注的接口」,于是永远不发。 按 S4 的原则(留着一个永远不发的类型比删掉更糟 —— 看代码的人会以为它有数据), 补接口而不是删类型。 ## 接口 `PUT /api/accounts/{id}/remark`,权限点 `account:remark`(授予 super_admin / operation / support,与 `account:tag` 一致)。 - 与改标签一样走 `record_account_action()`:**审计 + 账号时间线一次写完**, 审计带 `account_id`,事件 detail 带 `{from, to}`; - **只做单个、不做批量**:备注是自由文本,给一堆账号写同一句备注没有实际意义 (标签是短枚举才有批量); - 内容 trim 后截断到 256(与 `accounts.remark` 列宽一致),清空也记一条事件; - 备注没变化时直接返回、**不写事件**:否则时间线会被「点开保存了一下但没改」刷满。 ## 权限点新增前的安全检查 新权限点会被 `custom_permissions` 覆盖,所以先查了库: `admin` / `kf1_test` 的 `custom_permissions` 是 **JSON null**(不是 SQL NULL), Python 侧取出来是 `None` → `get_user_permissions` 走角色默认。 也就是说**没有任何用户在用自定义权限列表**,新增权限点不会把谁的能力锁掉。 (这一点很容易看错:`IS NULL` 判出来是「有自定义权限」,`JSON_LENGTH` 还是 1。) ## 前端 - 详情页「备注」一行改为可编辑(铅笔 → 输入框 → 保存/取消,回车即存); - `EVENT_STYLE` 补 `remark_changed`,时间线里能看到备注的每次变更。 ## 一个自己踩到又修掉的坑 第一版把两个 `useState` 写在了 `if (!accountId) return ` **之后** —— 正是本次会话刚在 LoginTasksPage 修掉的那类 hooks 顺序违规(`react-hooks/rules-of-hooks` 直接报错)。已上移到早期 return 之前的 state 区块。lint 抓到,构建不报,属于 「能跑但迟早炸」的那类问题。 ## 验证 - `TZ=Asia/Shanghai`:**426 passed / 6 skipped**(S4 后 420 → 新增 6 条) - `TZ=UTC`(相关文件)77 passed;ruff check/format 全绿 - 前端 `tsc -b && vite build` 通过、`npm run lint` 0 errors - 新增用例:写入并带 from/to 事件与审计关联、未变化不写事件、清空也记一条、 trim + 截断到 256、账号不存在 404、**新权限点确实在三个角色的默认权限里** (防止「加了接口但没人能用」) --- docs/设计-账号全生命周期.md | 12 +-- tests/test_account_actions.py | 76 +++++++++++++++++++ web/backend/permissions.py | 3 + web/backend/routers/accounts.py | 36 +++++++++ web/backend/schemas.py | 4 + .../services/account_events_service.py | 1 + web/frontend/src/api/accounts.ts | 2 + web/frontend/src/pages/AccountDetailPage.tsx | 58 +++++++++++++- 8 files changed, 184 insertions(+), 8 deletions(-) diff --git a/docs/设计-账号全生命周期.md b/docs/设计-账号全生命周期.md index 1c96c21..d19f401 100644 --- a/docs/设计-账号全生命周期.md +++ b/docs/设计-账号全生命周期.md @@ -265,16 +265,18 @@ CREATE INDEX ix_account_events_ledger | auth | `relogin_ok` / `relogin_failed` | 自动重登结果 | `login_service` / `ck_relogin.settle_results` | — | | ops | `assigned` | 分配 / 取消分配 | `audit_service.record_account_action`、批量走 `emit_bulk` | — | | ops | `tag_changed` | 标签变更(detail 带 from/to) | 同上 | — | +| ops | `remark_changed` | 备注变更(detail 带 from/to) | `accounts.set_account_remark`(单个) | — | | business | `recharge` | 充值到账 | 收集器(S5) | + ¥ | | business | `exchange` | 兑换(含消耗积分) | 收集器(S5) | − 积分 | | business | `points_credit` | 积分到账 | 收集器(S5) | + 积分 | | business | `elite_opened` / `esports_opened` | 开通精英宝典 / 电竞手册 | `douyu_runner_manual` | — | | business | `bind_ok` / `rebind` | 角色绑定 / 换绑 | 收集器(S5) | — | -> **不落的两个**: -> - `imported`:导入时间直接读 `accounts.created_at`,不落表(设计 §3.5 起就这么定); -> - `remark_changed`:**目前没有改备注的接口**,所以这个类型没有发射点。要么补接口, -> 要么从表里删掉 —— 留着一个永远不发的类型只会让人以为它有数据。 +> **只有一个不落表**:`imported` —— 导入时间直接读 `accounts.created_at`。 +> +> `remark_changed` 曾长期处于「定义了但没有发射点」(因为压根没有改备注的接口), +> 2026-10-04 补上 `PUT /api/accounts/{id}/remark` 后接顺。 +> **教训**:留着一个永远不发的类型,比删掉它更糟 —— 看代码的人会以为它有数据。 > > **`ck_invalid` 只认「转变」**(2026-10-04 S4 修正):旧实现每轮巡检只要判定 invalid > 就发一条,注释写着「避免每轮刷屏」而代码并没有做到 —— 6 小时一轮、失效一周 = @@ -438,7 +440,7 @@ WHERE a.cookie <> src.cookie; | **S1** | 迁移 0042:加列 + 回填 + 自校验;`accounts` 成为状态载体 | 账号能独立描述登录态 | — | | **S2** | 读写一次切换:12 个文件、24 读点 / 10 写点全部改 `LoginTask.*` → `Account.*`;批次行复用逻辑重写;在途子查询 + 索引 | 登录记录与 CK 彻底解耦 | S1 | | **S3** ✅ | 状态收口(**已完成 2026-10-04**):`account_lifecycle` 提供 `derive_status()` / `sync_status()` / `set_manual_state()` / `on_login_success()`;9 处判据写入点全部接线;6 状态状态机上线;自动化选行排除 `disabled`/`archived`;新增 `account:manage` 权限与停用/归档/恢复接口;前端列表加状态列与筛选、详情页加状态卡与操作入口 | `status` 可信 | S2 | -| **S4** ✅ | 事件发射点补齐(**已完成 2026-10-04**):`assigned` / `tag_changed` 接上(此前定义了却从没发过)、新增 `ck_cleared`、`ck_invalid` 改为只在转变时发;`audit_logs.account_id` 在分配/标签/检测/清 CK 处打通;新增 `emit_bulk`(批量操作逐账号写事件)与 `record_account_action`(审计+事件一次写完);时间线支持 `category` 筛选;前端补 `status_changed` / `ck_cleared` 样式与分类筛选 | 时间线完整 | S2 | +| **S4** ✅ | 事件发射点补齐(**已完成 2026-10-04**):`assigned` / `tag_changed` 接上(此前定义了却从没发过)、新增 `ck_cleared`、`ck_invalid` 改为只在转变时发;`audit_logs.account_id` 在分配/标签/检测/清 CK 处打通;新增 `emit_bulk`(批量操作逐账号写事件)与 `record_account_action`(审计+事件一次写完);时间线支持 `category` 筛选;前端补 `status_changed` / `ck_cleared` 样式与分类筛选;补上改备注接口 `PUT /api/accounts/{id}/remark`(`remark_changed` 从此有发射点)| 时间线完整 | S2 | | **S5** | 业务事实直发事件:开通 / 充值 / 积分 / 兑换 / 绑定在任务成功时写事件;`douyu_tasks` 加保留策略 | 台账可用,687MB 可控 | S4 | | **S6** | 账号页重构:列表加状态与关键列;详情页做成「生命周期主页」(状态卡 + 台账 + 时间线 + 操作);`bind_status` 三列拆解落地 | 账号管理真正独立 | S3、S5 | | **S7** | 账号检测并入账号域(`account-check` 从「粘贴文本」改为「按账号选」,结果落事件) | 检测不再是孤岛 | S6 | diff --git a/tests/test_account_actions.py b/tests/test_account_actions.py index feafea2..5fc3466 100644 --- a/tests/test_account_actions.py +++ b/tests/test_account_actions.py @@ -13,6 +13,8 @@ S4 之前的状态(都是「定义了却从没发过」): from datetime import UTC, datetime +import pytest + from tests.support.db import get_test_engine, make_test_session from web.backend.models import Account, AccountEvent, AuditLog, User from web.backend.services import account_events_service as svc @@ -413,3 +415,77 @@ class TestBatchDeleteRecordsIds(_Base): assert audit.detail is not None for account_id in ids: assert str(account_id) in audit.detail + + +class TestRemarkEndpoint(_Base): + """改备注:备注是自由文本,走单个接口(标签那种短枚举才有批量)。""" + + def _set(self, account_id: int, remark, user=None): + from web.backend.routers.accounts import set_account_remark + from web.backend.schemas import AccountRemark + + return set_account_remark( + account_id, + AccountRemark(remark=remark), + db=self.session, + current=user or self.admin, + ) + + def test_sets_remark_and_emits_event_with_from_to(self): + account = self.make_account("a", remark="老备注") + res = self._set(account.id, "新备注") + self.session.refresh(account) + + assert res["success"] and account.remark == "新备注" + events = self.events(account.id, svc.EVENT_REMARK_CHANGED) + assert len(events) == 1 + assert events[0].category == svc.CATEGORY_OPS + assert events[0].detail == {"from": "老备注", "to": "新备注"} + assert events[0].actor_id == self.admin.id + + audit = self.latest_audit("account:remark") + assert audit is not None and audit.account_id == account.id + + def test_unchanged_remark_is_noop(self): + """没变就别写事件 —— 否则时间线会被「保存了一下但没改」刷满。""" + account = self.make_account("a", remark="一样") + res = self._set(account.id, "一样") + assert res["success"] is True + assert self.events(account.id, svc.EVENT_REMARK_CHANGED) == [] + assert self.latest_audit("account:remark") is None + + def test_empty_string_clears_remark_and_is_recorded(self): + account = self.make_account("a", remark="要清掉") + self._set(account.id, " ") + self.session.refresh(account) + assert account.remark == "" + events = self.events(account.id, svc.EVENT_REMARK_CHANGED) + assert len(events) == 1 + assert events[0].detail == {"from": "要清掉", "to": ""} + + def test_remark_is_trimmed_and_truncated(self): + account = self.make_account("a") + self._set(account.id, " " + "字" * 400 + " ") + self.session.refresh(account) + assert account.remark == "字" * 256 + + def test_missing_account_returns_404(self): + from fastapi import HTTPException + + with pytest.raises(HTTPException) as exc: + self._set(999999, "x") + assert exc.value.status_code == 404 + + def test_permission_granted_to_operation_and_support(self): + """新权限点必须在角色默认里 —— 否则加完接口没人能用。 + + 注:`custom_permissions` 若被显式设过会覆盖角色默认,本次确认过库里 + admin / kf1_test 的 `custom_permissions` 是 JSON null(Python 侧为 None), + 走的是角色默认,所以新增权限点不会把它们锁掉。 + """ + from web.backend.permissions import ROLE_PERMISSIONS + + for role in ("super_admin", "operation", "support"): + assert "account:remark" in ROLE_PERMISSIONS[role], ( + f"{role} 缺少 account:remark" + ) diff --git a/web/backend/permissions.py b/web/backend/permissions.py index ed64f98..a0b4bf7 100644 --- a/web/backend/permissions.py +++ b/web/backend/permissions.py @@ -15,6 +15,7 @@ PERMISSIONS = { "account:view_assigned": "查看分配给自己的账号", "account:import": "导入账号", "account:tag": "修改账号标签", + "account:remark": "修改账号备注", "account:check": "账号检测", "account:assign": "分配账号给客服", "account:delete": "删除账号", @@ -67,6 +68,7 @@ ROLE_PERMISSIONS = { "account:view_all", "account:import", "account:tag", + "account:remark", "account:check", "account:assign", "account:manage", @@ -100,6 +102,7 @@ ROLE_PERMISSIONS = { "support": [ "account:view_assigned", "account:tag", + "account:remark", "cookie:operate", "douyu:task", "yyb:session", diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index bb996a1..a09dca3 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -26,6 +26,7 @@ from ..schemas import ( AccountImport, AccountLifecycleUpdate, AccountOut, + AccountRemark, AccountTag, BatchAssign, ) @@ -37,6 +38,7 @@ from ..services.account_events_service import ( EVENT_ESPORTS_OPENED, EVENT_EXCHANGE, EVENT_RECHARGE, + EVENT_REMARK_CHANGED, EVENT_TAG_CHANGED, emit_bulk, ) @@ -563,6 +565,40 @@ def set_account_automation( } +@router.put("/{account_id}/remark") +def set_account_remark( + account_id: int, + req: AccountRemark, + db: Session = Depends(get_db), + current: User = Depends(require_permission("account:remark")), +): + """设置单个账号的备注。 + + 备注是自由文本(标签是短枚举),所以走单个接口、不做批量 —— 批量给一堆账号写 + 同一句备注没有实际意义。与改标签一样:审计 + 账号时间线一次写完。 + """ + acc = db.query(Account).filter(Account.id == account_id).first() + if not acc: + raise HTTPException(status_code=404, detail="账号不存在") + + old_remark = acc.remark or "" + new_remark = (req.remark or "").strip()[:256] + if old_remark == new_remark: + return {"message": "备注未变化", "success": True, "remark": new_remark} + acc.remark = new_remark + record_account_action( + db, + current, + account=acc, + action="account:remark", + event_type=EVENT_REMARK_CHANGED, + title=f"备注:{old_remark or '(空)'} → {new_remark or '(空)'}", + detail={"from": old_remark, "to": new_remark}, + ) + db.commit() + return {"message": "备注已更新", "success": True, "remark": new_remark} + + def _apply_tag_bulk(db: Session, current: User, ids: list[int], tag: str) -> None: """批量改标签:更新 + 审计 + **逐账号写事件**。 diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 56dabbd..8ce13ab 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -148,6 +148,10 @@ class AccountTag(BaseModel): account_ids: list[int] | None = None +class AccountRemark(BaseModel): + remark: str | None = None + + class AccountLifecycleUpdate(BaseModel): """人工设定生命周期状态。 diff --git a/web/backend/services/account_events_service.py b/web/backend/services/account_events_service.py index a305651..25282da 100644 --- a/web/backend/services/account_events_service.py +++ b/web/backend/services/account_events_service.py @@ -47,6 +47,7 @@ EVENT_ELITE_OPENED = "elite_opened" # 精英宝典开通 EVENT_ESPORTS_OPENED = "esports_opened" # 电竞手册开通 EVENT_ASSIGNED = "assigned" # 分配客服 EVENT_TAG_CHANGED = "tag_changed" # 标签变更 +EVENT_REMARK_CHANGED = "remark_changed" # 备注变更 EVENT_STATUS_CHANGED = "status_changed" # 生命周期状态变迁(停用/归档/失效…) # 有符号金额的事件类型(用于页面「累计充值/累计消耗」): diff --git a/web/frontend/src/api/accounts.ts b/web/frontend/src/api/accounts.ts index f923314..3a93dda 100644 --- a/web/frontend/src/api/accounts.ts +++ b/web/frontend/src/api/accounts.ts @@ -28,6 +28,8 @@ export const accountApi = { api.post('/accounts/batch-assign', { account_ids, assigned_to }), assignmentsSummary: () => api.get('/accounts/assignments/summary'), + setRemark: (id: number, remark: string) => + api.put(`/accounts/${id}/remark`, { remark }), setTag: (id: number, tag: string) => api.put(`/accounts/${id}/tag`, { tag }), batchTag: (account_ids: number[], tag: string) => diff --git a/web/frontend/src/pages/AccountDetailPage.tsx b/web/frontend/src/pages/AccountDetailPage.tsx index f6aa98f..0b6f833 100644 --- a/web/frontend/src/pages/AccountDetailPage.tsx +++ b/web/frontend/src/pages/AccountDetailPage.tsx @@ -1,11 +1,11 @@ import { useCallback, useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { - Button, Card, Descriptions, Empty, Popconfirm, Select, Space, Spin, Statistic, Table, Tag, Tooltip, Typography, + Button, Card, Descriptions, Empty, Input, Popconfirm, Select, Space, Spin, Statistic, Table, Tag, Tooltip, Typography, } from 'antd'; import type { TableProps } from 'antd'; import { - ArrowLeftOutlined, ReloadOutlined, + ArrowLeftOutlined, EditOutlined, ReloadOutlined, } from '@ant-design/icons'; import { accountApi, type AccountEventItem, type AccountOverview } from '../api/modules'; import { getErrorMessage } from '../utils/error'; @@ -34,6 +34,7 @@ const EVENT_STYLE: Record = { esports_opened: { label: '电竞手册', color: 'volcano' }, assigned: { label: '分配', color: 'default' }, tag_changed: { label: '标签', color: 'default' }, + remark_changed: { label: '备注', color: 'default' }, }; /** 事件粗分类(与后端 `account_events_service.CATEGORY_*` 对应)。 */ @@ -90,6 +91,8 @@ export default function AccountDetailPage() { const [eventType, setEventType] = useState(''); const [category, setCategory] = useState(''); const [eventsLoading, setEventsLoading] = useState(false); + const [remarkEditing, setRemarkEditing] = useState(false); + const [remarkDraft, setRemarkDraft] = useState(''); const loadOverview = useCallback(async () => { if (!accountId) return; @@ -181,6 +184,21 @@ export default function AccountDetailPage() { } }; + + /** 保存备注:备注是自由文本,走单个接口(标签那种短枚举才有批量)。 */ + const handleSaveRemark = async () => { + if (!accountId) return; + try { + await accountApi.setRemark(accountId, remarkDraft.trim()); + message.success('备注已更新'); + setRemarkEditing(false); + void loadOverview(); + void loadEvents(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } + }; + const handleAutomation = async (autoManaged: boolean) => { if (!accountId) return; try { @@ -259,7 +277,41 @@ export default function AccountDetailPage() { {overview?.game_channel ? ({overview.game_channel}) : null} {overview?.bind_status || '-'} - {overview?.remark || '-'} + + {remarkEditing ? ( + + setRemarkDraft(e.target.value)} + onPressEnter={() => void handleSaveRemark()} + /> + + + + ) : ( + + {overview?.remark || '-'} + +