feat(account): 补上改备注接口,remark_changed 事件从此有发射点
`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 <Empty/>` **之后** ——
正是本次会话刚在 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、**新权限点确实在三个角色的默认权限里**
(防止「加了接口但没人能用」)
This commit is contained in:
+7
-5
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
"""批量改标签:更新 + 审计 + **逐账号写事件**。
|
||||
|
||||
|
||||
@@ -148,6 +148,10 @@ class AccountTag(BaseModel):
|
||||
account_ids: list[int] | None = None
|
||||
|
||||
|
||||
class AccountRemark(BaseModel):
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
class AccountLifecycleUpdate(BaseModel):
|
||||
"""人工设定生命周期状态。
|
||||
|
||||
|
||||
@@ -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" # 生命周期状态变迁(停用/归档/失效…)
|
||||
|
||||
# 有符号金额的事件类型(用于页面「累计充值/累计消耗」):
|
||||
|
||||
@@ -28,6 +28,8 @@ export const accountApi = {
|
||||
api.post<MessageCountResponse, MessageCountResponse>('/accounts/batch-assign', { account_ids, assigned_to }),
|
||||
assignmentsSummary: () =>
|
||||
api.get<AssignmentsSummary, AssignmentsSummary>('/accounts/assignments/summary'),
|
||||
setRemark: (id: number, remark: string) =>
|
||||
api.put<MessageResponse, MessageResponse>(`/accounts/${id}/remark`, { remark }),
|
||||
setTag: (id: number, tag: string) =>
|
||||
api.put<MessageResponse, MessageResponse>(`/accounts/${id}/tag`, { tag }),
|
||||
batchTag: (account_ids: number[], tag: string) =>
|
||||
|
||||
@@ -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<string, { label: string; color: string }> = {
|
||||
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 ? <Text type="secondary"> ({overview.game_channel})</Text> : null}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="绑定状态">{overview?.bind_status || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{overview?.remark || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">
|
||||
{remarkEditing ? (
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
size="small"
|
||||
maxLength={256}
|
||||
value={remarkDraft}
|
||||
placeholder="给这个账号写点备注"
|
||||
onChange={(e) => setRemarkDraft(e.target.value)}
|
||||
onPressEnter={() => void handleSaveRemark()}
|
||||
/>
|
||||
<Button size="small" type="primary" onClick={() => void handleSaveRemark()}>
|
||||
保存
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setRemarkEditing(false)}>
|
||||
取消
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
) : (
|
||||
<Space size={4}>
|
||||
<span>{overview?.remark || '-'}</span>
|
||||
<Tooltip title="编辑备注">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setRemarkDraft(overview?.remark || '');
|
||||
setRemarkEditing(true);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态变更">
|
||||
{formatTime(overview?.status_changed_at)}
|
||||
{overview?.status_reason ? (
|
||||
|
||||
Reference in New Issue
Block a user