Commit 07e5cabf by luoqi

feat(矩阵): 点格子先弹中间表看全量患者,确定才交给助手;助手不再默认最大化

主管点的是一个数字(「充填 · 半年到1年 420」),而他要为这批人负责。
现在多一步:摊开这一格圈定的**全部**患者(姓名/病历号/性别/年龄/主治医生/
上次就诊医生/专属客服),看过再点「确定,交给助手」。

🔴 与出确认单**同源**:GET /plans/assignments/cohort 用的是 propose 那段
   **同一个** cohortWhereSql + 同一个 SELECTION_ORDER。 别为它另写查询 ——
   排序一旦不同,第 1 页看到的人 ≠ 助手真会挑走的前 N 个,而且两边都不报错(T14)。
   实测:中间表标题 420,助手随后说「这批 50 人从符合条件的 420 人里挑」,对得上。

分页取,不一次拉全:最大的格子四千多人(测试服 充填·3年以上 4,159),
一次拉回来 DOM 撑不住,主管也不会逐行看完。
️ 这里**用 offset 不用游标**(与列表页那条纪律相反,刻意):候选集是静态快照、
   排序键确定性、而且主管要「第 3 / 84 页」这种可回跳的翻页。

 助手**默认不最大化**了(handoff 原来传 maximize: true):
   前面已经有中间表让他看过人,再铺满整屏等于把他刚看完的工作台盖掉,
   而他多半还要回去点下一格。要看大图,窗口自己有最大化按钮。

️ 粒子起点从"格子"改成中间表上的「确定」按钮 —— 从一个已被弹窗盖住的格子
   起飞,看起来像凭空出现。

越权闸两层都有:controller 一次(让"收 clinicId 必过闸"在源码上可数)+
service 一次(换任何入口进来都拦得住);测试两条都锁。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 291a3cdc
......@@ -614,6 +614,104 @@ export class AssignmentProposalService {
}
/**
* 🔴 **点格子之后、交给助手之前**那张中间表 —— 这一格圈定的**全部**患者,分页给。
*
* ⭐ 存在的理由:主管点的是一个数字(「充填 · 3 年以上 4,159」),而他要为这批人负责。
* 让他先看见"这 4,159 个人是谁"再决定交不交,比事后在确认单上补救便宜得多。
*
* 🔴 **必须与 `selectCandidates` 逐字同源**:同一个 `cohortWhereSql` + 同一个
* `SELECTION_ORDER`。⛔ 别在这里另写一份"差不多"的查询 ——
* 排序一旦不同,主管在第 1 页看到的人 ≠ 助手真会挑走的前 N 个,
* 而这件事**不会报错**(两张表各自都"对")。
*
* ⚠️ **分页用 offset,不用游标**(与列表页那条纪律相反,这里是刻意的):
* ① 这是一个**静态候选集**的浏览(主管操作的几分钟里池子基本不动),
* 不是"一直在新增"的时间流 —— 游标那条纪律防的是后者;
* ② 主管要的是"第 3 / 84 页"这种可回跳的翻页,游标给不了总页数;
* ③ 排序键是确定性的(assignment_id IS NULL → priority_score → patient_id),
* 同一 offset 两次取回同一批人。
* ⚠️ 代价:极深的翻页(几千页)offset 会变慢 —— 现实里最大的格子 4 千人 / 每页 50 = 84 页,
* 够用;真涨到十万级再换 keyset。
*/
async cohortPage(
scope: TenantScopeContext,
input: {
clinicId: string;
potentialTreatment?: string;
temperature?: TemperatureValue;
anchorMode?: AnchorModeValue;
personaTags?: string;
page?: number;
pageSize?: number;
},
now: Date = new Date(),
) {
// 🔴 越权闸:这条路会吐**患者姓名**,与提案同级别 —— ⛔ 不能只信前端传对
const clinicId = resolveClinicId(scope, input.clinicId);
const criteria: CohortCriteria = {
clinicId,
...(input.potentialTreatment ? { potentialTreatment: input.potentialTreatment } : {}),
...(input.temperature ? { temperature: input.temperature } : {}),
anchorMode: input.anchorMode ?? AnchorMode.DIAGNOSIS,
...(input.personaTags ? { personaTags: input.personaTags } : {}),
};
assertCohortCriteria(criteria);
const pageSize = Math.min(Math.max(input.pageSize ?? 50, 1), 200);
const page = Math.max(input.page ?? 1, 1);
const offset = (page - 1) * pageSize;
const [{ total }, rows] = await Promise.all([
this.countCandidates(scope, criteria, now).then((c) => ({ total: c.total })),
this.prisma.$queryRaw<
Array<{ planId: string; patientId: string; priorityScore: number; assignmentId: string | null }>
>(
Prisma.sql`
SELECT fp.id AS "planId", fp.patient_id AS "patientId",
fp.priority_score AS "priorityScore", fp.assignment_id AS "assignmentId"
FROM followup_plans fp
JOIN patients p ON p.id = fp.patient_id
WHERE ${cohortWhereSql(scope, criteria, now)}
${SELECTION_ORDER}
LIMIT ${pageSize} OFFSET ${offset}
`,
),
]);
// 身份走**同一个** patientsOf —— 与确认单的患者行逐字段同源(姓名/病历号/性别/年龄/两个医生)
const { info, dedicated } = await this.patientsOf(rows.map((r) => r.patientId));
const roster = await this.roster.list(scope, clinicId);
const agentName = new Map(roster.agents.map((a) => [a.userId, a.name]));
return {
clinicId,
total,
page,
pageSize,
items: rows.map((r) => {
const p = info.get(r.patientId);
const owner = dedicated.get(r.patientId) ?? null;
return {
planId: r.planId,
patientId: r.patientId,
patientName: p?.name ?? null,
medicalRecordNumber: p?.mrn ?? null,
gender: p?.gender ?? null,
age: p?.age ?? null,
attendingDoctor: p?.preferredDoctor ?? null,
lastVisitDoctor: p?.lastVisitDoctor ?? null,
/// ⭐ 专属客服要显示:主管一眼看出"这批里有多少本来就是谁的客户"
dedicatedTo: owner,
dedicatedToName: owner ? (agentName.get(owner) ?? null) : null,
/// ⭐ 重复召回标记 —— 之前被分过的人再次出现,主管有权先知道
repeated: r.assignmentId != null,
priorityScore: Number(r.priorityScore ?? 0),
};
}),
};
}
/**
* 两个基数的**沿用** —— 取该主管在该诊所上一次分配用的容量与时效。
*
* ⭐ 这是「先出全景确认单、主管反馈调整」这个设计的兑现点:
......
......@@ -171,6 +171,46 @@ export class AssignmentController {
* 走 HTTP 直接换掉那张卡,不用等模型再跑一轮(它也没有别的事可做)。
* ⚠️ 纯只读,和 propose 一样 —— **一个字都没写库**。
*/
/**
* 🔴 **点格子之后那张中间表** —— 这一格圈定的全部患者,分页。
*
* ⚠️ 与 `propose` 走**同一段** `cohortWhereSql` + 同一个排序键(见 service 注释):
* 主管在这张表第 1 页看到的人,就是助手会先挑走的那批。⛔ 别为它另写查询。
* ⚠️ 纯只读,一个字没写库(与 propose 同性质)。
*/
@Get('cohort')
@RequirePermission(Permission.PLAN_DISPATCH)
@ApiOperation({
summary: '这一格圈定的全部患者(分页)—— 交给助手之前先给主管过目',
description:
'与出确认单**同源**:同一个人群过滤 + 同一个排序键。' +
'offset 分页(候选集是静态快照、且主管要可回跳的页码);单页上限 200。',
})
async cohort(
@TenantScope() scope: TenantScopeContext,
@Query('clinicId') clinicId: string,
@Query('potentialTreatment') potentialTreatment?: string,
@Query('temperature') temperature?: string,
@Query('anchor') anchor?: string,
@Query('personaTags') personaTags?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.proposals.cohortPage(scope, {
// 🔴 越权闸:这条路会吐**患者姓名**。service 里还会再过一次(幂等)——
// 两层都留着是刻意的:controller 这层让"收了 clinicId 就必须过闸"这条纪律
// 在**源码上可数**(tests/mcp-clinic-scope.spec.ts 就是数这个),
// service 那层保证换任何入口进来都拦得住。
clinicId: resolveClinicId(scope, clinicId),
...(potentialTreatment ? { potentialTreatment } : {}),
...(temperature ? { temperature: temperature as never } : {}),
anchorMode: parseAnchorMode(anchor),
...(personaTags ? { personaTags } : {}),
...(page ? { page: Number(page) } : {}),
...(pageSize ? { pageSize: Number(pageSize) } : {}),
});
}
@Post('propose/refill')
@RequirePermission(Permission.PLAN_DISPATCH)
@ApiOperation({
......
......@@ -105,6 +105,8 @@ describe('MCP 诊所 id —— 不许模型自己编', () => {
'utf8',
);
expect(src).toMatch(/const clinicId = resolveClinicId\(scope, input\.clinicId\)/);
// ⭐ 中间表(cohortPage)与提案同性质 —— 同样会吐患者姓名,闸也必须在 service 里
expect(src).toMatch(/cohortPage\([\s\S]{0,1200}?resolveClinicId\(scope, input\.clinicId\)/);
// ⛔ 不许再从 input 里直接解构出来用
expect(src).not.toMatch(/const \{ clinicId, potentialTreatment \} = input/);
});
......
......@@ -2,6 +2,7 @@
import type {
AgentWorkloadResponse,
AnchorModeValue,
CreateAssignmentRequest,
CreateAssignmentResponse,
ListAgentsResponse,
......@@ -15,6 +16,37 @@ import type {
import { api } from '@/lib/api-client';
/**
* 中间表的一行 —— 字段与确认单的患者行**逐个对齐**(服务端同一个 `patientsOf`)。
* ⛔ 别在这里加确认单没有的字段:两张表看着不一样,主管会以为是两批人。
*/
export interface CohortPatientRow {
planId: string;
patientId: string;
patientName: string | null;
medicalRecordNumber: string | null;
gender: string | null;
age: number | null;
/** 主治 / 偏好医生(病历里出现频次最高的) */
attendingDoctor: string | null;
/** 上次到诊当天那份病历的医生 */
lastVisitDoctor: string | null;
/** 专属客服(有就显示名字)—— 主管一眼看出这批里有多少本来就是谁的客户 */
dedicatedTo: string | null;
dedicatedToName: string | null;
/** 之前被分过的人又出现了 */
repeated: boolean;
priorityScore: number;
}
export interface CohortPage {
clinicId: string;
total: number;
page: number;
pageSize: number;
items: CohortPatientRow[];
}
/**
* 批次分配 API。
*
* ⚠️ 路径都在 `plans/assignments` 之下 —— **别写成 `/plans/agents`**:
......@@ -95,6 +127,33 @@ export const assignmentsApi = {
refill: (body: RefillProposalRequest) =>
api.post<AssignmentProposal>('/pac/v1/plans/assignments/propose/refill', body),
/**
* 这一格圈定的**全部**患者(分页)—— 点格子之后、交给助手之前那张中间表。
*
* ⚠️ 与出确认单**同源**(服务端同一段 SQL + 同一个排序键):
* 第 1 页看到的人就是助手会先挑走的那批。⛔ 前端别自己再排一次序。
*/
cohort: (q: {
clinicId: string;
potentialTreatment?: string;
temperature?: string;
anchor?: AnchorModeValue;
personaTags?: string;
page?: number;
pageSize?: number;
}) =>
api.get<CohortPage>('/pac/v1/plans/assignments/cohort', {
query: {
clinicId: q.clinicId,
potentialTreatment: q.potentialTreatment,
temperature: q.temperature,
anchor: q.anchor,
personaTags: q.personaTags,
page: q.page == null ? undefined : String(q.page),
pageSize: q.pageSize == null ? undefined : String(q.pageSize),
},
}),
agents: (clinicId: string, include?: string[]) =>
api.get<ListAgentsResponse>(
`/pac/v1/plans/assignments/agents?clinicId=${encodeURIComponent(clinicId)}` +
......
......@@ -221,7 +221,7 @@ export function PoolMatrix({
{anchorMode === AnchorMode.LAST_VISIT
? // ⚠️ 末诊是**患者级**的:同一个人的几个潜在治疗必然落同一档 —— 不说清楚,
// 主管会以为"这人三个机会都刚诊断"。⛔ 别省这半句。
'末诊按人算,所以同一个人的几个治疗项都在同一档。'
'末诊按人算同一个人的几个治疗项都在同一档。'
: '一个人有几个潜在治疗就出现在几行。'}
</p>
......
'use client';
import { useEffect, useRef, useState } from 'react';
import { X } from 'lucide-react';
import { ANCHOR_MODE_META, TEMPERATURE_META, type AnchorModeValue, type TemperatureValue } from '@pac/types';
import { assignmentsApi, type CohortPage } from '@/components/plans/assignments-api';
import { cn, formatGender } from '@/lib/utils';
/**
* 「点了一格,先看看这批人是谁」—— 矩阵与助手之间的**中间一步**(2026-08-11 产品定)。
*
* ⭐ 存在的理由:主管点的是一个数字(「充填 · 3 年以上 4,159」),而他要为这批人负责。
* 在交给助手之前先摊开"这些人是谁",比事后在确认单上一个个挑出来便宜得多。
*
* 🔴 **这张表与确认单必须是同一批人、同一个顺序**:数据走
* `GET /plans/assignments/cohort`,服务端用的是**出确认单那段同样的** SQL 与排序键。
* ⛔ 前端不许再排一次序、不许本地过滤 —— 那会让"我在这看到的前 20 个"跟
* "助手挑走的前 20 个"对不上,而且两边都不报错(T14)。
*
* ⚠️ **分页取**,不是一次拉全:最大的格子四千多人,一次拉回来 DOM 也撑不住,
* 而主管本来也不会逐行看完 —— 他要的是"抽查几页 + 知道总数对不对"。
*/
export function CohortPreview({
clinicId,
cell,
anchorMode,
onCancel,
onConfirm,
}: {
clinicId: string;
cell: { treatment: string; treatmentZh: string; temperature: TemperatureValue; count: number };
anchorMode: AnchorModeValue;
onCancel: () => void;
/// 确定 —— 带上「确定」按钮的视口矩形,数据流粒子从那里起飞(⛔ 不能事后再查,窗已经关了)
onConfirm: (from: { x: number; y: number; w: number; h: number }) => void;
}) {
const [page, setPage] = useState(1);
const [data, setData] = useState<CohortPage | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const okRef = useRef<HTMLButtonElement>(null);
const PAGE_SIZE = 50;
useEffect(() => {
let alive = true;
setLoading(true);
assignmentsApi
.cohort({
clinicId,
potentialTreatment: cell.treatment,
temperature: cell.temperature,
anchor: anchorMode,
page,
pageSize: PAGE_SIZE,
})
.then((r) => alive && (setData(r), setError(null)))
.catch((e) => alive && setError(e instanceof Error ? e.message : '取患者失败'))
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, [clinicId, cell.treatment, cell.temperature, anchorMode, page]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onCancel();
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onCancel]);
const total = data?.total ?? cell.count;
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const tag = ANCHOR_MODE_META[anchorMode].batchTag;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/30 p-4"
onClick={onCancel}
>
<div
className="flex max-h-[86vh] w-full max-w-[64rem] flex-col overflow-hidden rounded-xl bg-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-none items-baseline gap-2 border-b px-4 py-3">
<span className="text-[13.5px] font-semibold text-slate-900">
{cell.treatmentZh} · {TEMPERATURE_META[cell.temperature].zh}
{tag ? `(${tag})` : ''}
</span>
{/* ⚠️ 总数取**服务端**的 total,⛔ 不用格子上那个数:两者应当一致,
不一致时要让主管看见的是**真会被分的那个**(格子可能是几秒前的快照)。 */}
<span className="nums text-[12px] text-slate-500">{total}</span>
{data && total !== cell.count && (
<span className="text-[11px] text-amber-700">(格子上显示 {cell.count},池子刚变过)</span>
)}
<span className="ml-auto text-[11px] text-slate-400">
按分配顺序排列,助手会从上往下取
</span>
<button
type="button"
onClick={onCancel}
aria-label="关闭"
className="rounded-md p-1 text-slate-500 hover:bg-slate-100"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{error ? (
<p className="py-10 text-center text-[12px] text-rose-600">{error}</p>
) : !data ? (
<div className="space-y-2 p-4">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="h-6 animate-pulse rounded bg-slate-100" />
))}
</div>
) : (
<table className="w-full border-collapse text-[12.5px] text-slate-700">
<thead>
<tr className="sticky top-0 z-10 bg-slate-50 shadow-[inset_0_-1px_0_#E2E8F0]">
{['#', '患者', '病历号', '性别', '年龄', '主治医生', '上次就诊医生', '专属客服'].map(
(h, i) => (
<th
key={h}
className={cn(
'whitespace-nowrap px-2.5 py-1.5 text-[11px] font-medium text-slate-500',
i === 0 ? 'w-12 text-right' : 'text-left',
)}
>
{h}
</th>
),
)}
</tr>
</thead>
<tbody className={cn(loading && 'opacity-50')}>
{data.items.map((r, i) => (
<tr key={r.planId} className="border-t hover:bg-slate-50">
<td className="nums px-2.5 py-1.5 text-right text-[11px] text-slate-400">
{(data.page - 1) * data.pageSize + i + 1}
</td>
<td className="px-2.5 py-1.5">
<span className="font-medium text-slate-900">
{r.patientName ?? `#${r.patientId.slice(0, 8)}`}
</span>
{/* ⭐ 之前被分过的人又出现了 —— 主管有权先知道,⛔ 别等他事后发现 */}
{r.repeated && (
<span className="ml-1.5 rounded bg-amber-50 px-1 py-px text-[10px] text-amber-700">
分过
</span>
)}
</td>
<td className="nums px-2.5 py-1.5 text-slate-500">{r.medicalRecordNumber ?? '—'}</td>
<td className="px-2.5 py-1.5 text-slate-500">{formatGender(r.gender) || '—'}</td>
<td className="nums px-2.5 py-1.5 text-slate-500">{r.age ?? '—'}</td>
<td className="px-2.5 py-1.5 text-slate-600">{r.attendingDoctor ?? '—'}</td>
<td className="px-2.5 py-1.5 text-slate-600">{r.lastVisitDoctor ?? '—'}</td>
<td className="px-2.5 py-1.5 text-slate-600">{r.dedicatedToName ?? '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="flex flex-none items-center gap-2 border-t px-4 py-2.5">
{/* 翻页 —— ⚠️ 页码要给全(第 3 / 84 页):主管抽查时要能回跳,这也是这里
用 offset 而不是游标的原因(见服务端 cohortPage 注释)。 */}
<button
type="button"
disabled={page <= 1 || loading}
onClick={() => setPage((p) => Math.max(1, p - 1))}
className="rounded-md border px-2.5 py-1 text-[12px] text-slate-600 hover:bg-slate-50 disabled:opacity-40"
>
上一页
</button>
<span className="nums text-[11.5px] text-slate-500">
{data?.page ?? page} / {pages}
</span>
<button
type="button"
disabled={page >= pages || loading}
onClick={() => setPage((p) => Math.min(pages, p + 1))}
className="rounded-md border px-2.5 py-1 text-[12px] text-slate-600 hover:bg-slate-50 disabled:opacity-40"
>
下一页
</button>
<span className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-lg px-3 py-1.5 text-[12.5px] text-slate-600 hover:bg-slate-100"
>
取消
</button>
<button
ref={okRef}
type="button"
onClick={() => {
const el = okRef.current;
const b = el?.getBoundingClientRect();
onConfirm(
b
? { x: b.left, y: b.top, w: b.width, h: b.height }
: { x: window.innerWidth / 2, y: window.innerHeight / 2, w: 0, h: 0 },
);
}}
className="rounded-lg bg-brand-600 px-3 py-1.5 text-[12.5px] font-medium text-white hover:bg-brand-700"
>
确定,交给助手
</button>
</span>
</div>
</div>
</div>
);
}
......@@ -11,6 +11,7 @@ import {
import { plansApi, type PoolMatrix as PoolMatrixData } from '@/components/plans/plans-api';
import { PoolMatrix } from '@/components/plans/pool-matrix';
import { useAssignmentSyncStore } from '@/stores/assignment-sync-store';
import { CohortPreview } from './cohort-preview';
import { useAssistantStore } from '@/stores/assistant-store';
import { cn } from '@/lib/utils';
......@@ -62,6 +63,9 @@ function handoff(c: Picked, mode: AnchorModeValue) {
});
}
/// 中间表要用的那一格(不含 rect —— 粒子起点改成「确定」按钮,见 CohortPreview)
type PickedCell = Omit<Picked, 'rect'>;
export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
const [matrix, setMatrix] = useState<PoolMatrixData | null>(null);
const [error, setError] = useState<string | null>(null);
......@@ -116,7 +120,20 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
* ⛔ 不会出现"开关已经切了、格子还是旧那版、点下去按新那版圈人"这种错位。
*/
const dataMode = matrix?.anchorMode ?? mode;
const pick = useCallback((c: Picked) => handoff(c, dataMode), [dataMode]);
/**
* 🔴 点格子**不再直接移交**(2026-08-11 产品定):先弹中间表,让主管看清这批人是谁,
* 点「确定」才交给助手。
* ⛔ 别为了"少一步"把它改回一点就走 —— 这一步换来的是主管对这批人**看过一眼**,
* 而分配是他签字的动作。
* ⚠️ 只存格子语义,⛔ 不存点击时的 rect:粒子起点已改成中间表上那个「确定」按钮
* (从一个已经被弹窗盖住的格子起飞,看起来像凭空出现)。
*/
const [preview, setPreview] = useState<PickedCell | null>(null);
const pick = useCallback((c: Picked) => {
const { rect: _rect, ...cell } = c;
setPreview(cell);
}, []);
return (
<div className="flex w-full flex-col overflow-hidden rounded-lg border bg-white">
......@@ -177,6 +194,19 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
</div>
)}
</div>
{preview && clinicId && (
<CohortPreview
clinicId={clinicId}
cell={preview}
anchorMode={dataMode}
onCancel={() => setPreview(null)}
onConfirm={(from) => {
setPreview(null);
handoff({ ...preview, rect: from }, dataMode);
}}
/>
)}
</div>
);
}
......@@ -73,8 +73,15 @@ export const useAssistantStore = create<AssistantState>((set, get) => ({
handoff: ({ text, from, count, treatment, temperature }) => {
// ① 先发语义事件:舞台层据此放数据流,大脑层据此进"吸收"姿态
emitPetEvent({ type: 'cohort_handoff', payload: { count, treatment, temperature, from } });
// ② 粒子飞完再开窗 —— 先开窗会把飞行路径整个盖住,演了等于没演
window.setTimeout(() => get().ask(text, { maximize: true }), STREAM_TOTAL_MS);
/**
* ② 粒子飞完再开窗 —— 先开窗会把飞行路径整个盖住,演了等于没演。
*
* ⭐ **默认不最大化**(2026-08-11 产品定,原来是 `maximize: true`)。
* 移交现在前面多了一张中间表(主管已经看过这批人是谁了),再一上来就铺满整屏,
* 等于把他刚看完的工作台整个盖掉 —— 而他多半还要回去点下一格。
* ⛔ 别改回 true:要看大图,助手窗口自己有最大化按钮,那是**他**的决定。
*/
window.setTimeout(() => get().ask(text), STREAM_TOTAL_MS);
},
consume: (seq) => {
// 只清掉自己那一条 —— 期间若又来了新请求(seq 更大),不能连它一起清
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment