Commit 729a7709 by luoqi

fix(plan): 医生名单缓存改 10 分钟 + 重算收尾主动清;客服也能自助返池

## ① 医生名单搜不到(测试服实证)
「上次医生 / 偏好医生」的候选名单缓存 6 小时,**且没有失效钩子**。三个派生列刚上线
全是 NULL,第一次打开筛选面板就把 `[]` 缓存了 6 小时;之后重算把值填上了,客服那边
照旧「暂无医生名单」,输入框搜谁都搜不到 —— 表现成"这个医生搜不到",没人会想到是 Redis。

缓存久的真实代价不是"名单旧几小时",是**排查成本**。两处改:
  · TTL 6h → 10min(仍挡得住"每开一次面板跑一次 DISTINCT",44 万行有索引,不贵)
  · recompute-persona 收尾主动删该 host 各 tenant 的 key → 刚跑完就能搜到,不用等 10 分钟
key 拼法导出成 doctorOptionsCacheKey,CLI 不再手写字符串(写歪了是静默删空)。

## ② staff 自助返池
认错人 / 打不通 / 不该由我跟,客服自己就该能退回池,否则只能挂着占位,或者硬走
「关闭机会」—— 那会污染放弃原因统计。

️ 光给权限是有洞的:客服 A 能把客服 B 手里的单退回池再自己认领,认领闸挡的是
"没认领就作业",挡不住"先把别人的单退了"。所以配套加了归属闸 assertCanRecycle:
  · 有 PLAN_VIEW_ALL(leader/admin)→ 可退任意人的单,这是"组长回收"的原义
  · 没有(staff)→ 只能退自己认领的,否则 PLAN_CLAIMED_BY_OTHER(带占用人,前端能提示是谁)
判据取权限不取角色字符串 —— 权限模型的真理源是 ROLE_PERMISSIONS,service 不该再认一次角色。

顺带订正 recycle-scheduler 里"staff 无返池权限所以自动回收是唯一释放路径"的注释(已不成立)。

663 tests / 42 suites 通过(新增 6 条:归属闸四态 + 权限矩阵两条)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent a8687e0a
Pipeline #3480 failed in 0 seconds
......@@ -14,6 +14,8 @@ import { Logger } from '@nestjs/common';
import { AppModule } from '../app.module';
import { PersonaService } from '../modules/persona/persona.service';
import { PrismaService } from '../prisma/prisma.service';
import { RedisService } from '../redis/redis.service';
import { doctorOptionsCacheKey } from '../modules/plan/plan.service';
import { runPool } from '../common/run-pool';
interface Args {
......@@ -158,6 +160,25 @@ async function bootstrap() {
`Done: success=${ok} partial=${partial} refreshed=${refreshed} ` +
`unchanged=${unchanged} noop=${noop} failed=${failed}`,
);
// ⭐ 删「上次医生 / 偏好医生」候选名单的缓存 —— 重算顺路刷新了 patient_profiles 的医生列,
// 缓存里那份就旧了。TTL 已收到 10 分钟(见 plan.service.ts),这里再删一次是为了**即时可见**:
// 刚跑完重算就去筛选面板搜医生,不该还要等 10 分钟。
// 2026-07-29 测试服的坑:三列刚上线全是 NULL,第一次开面板缓存了 `[]`,后来重算填了值,
// 前端照旧「暂无医生名单」——表现成"这医生搜不到",没人会想到是 Redis。
// 失败只告警:名单缓存过期本来就只是慢一点,不该让一趟几小时的重算以非零码收场。
try {
const redis = app.get(RedisService);
const tenants = await prisma.patient.findMany({
where: { hostId: host.id },
select: { tenantId: true },
distinct: ['tenantId'],
});
for (const t of tenants) await redis.del(doctorOptionsCacheKey(host.id, t.tenantId));
logger.log(`医生名单缓存已清:${tenants.length} 个 tenant`);
} catch (err) {
logger.warn(`医生名单缓存清理失败(不影响重算结果):${err instanceof Error ? err.message : err}`);
}
} catch (err) {
new Logger('recompute-persona').error(err instanceof Error ? err.stack : String(err));
process.exitCode = 2;
......
......@@ -16,7 +16,7 @@ import { BizError } from '../../common/errors/biz-error';
* 过闸:提交通话结果 / 关闭机会、重生成话术(含 SSE)、话术反馈、召回反馈
* 不过闸:
* · `assign`(认领本身就是入口,过闸会变成死锁)
* · `recycle`(PLAN_RECYCLE 是 **leader 独占**,语义就是"回收 staff 已认领的单",
* · `recycle`(有自己的归属规则,见 assertCanRecycle —— 组长能回收任意人的单,
* 按"必须自己认领"拦会把组长回收功能整个废掉)
* · `recompute`(ops/dev 工具,走 PLAN_ASSIGN 权限)
*
......@@ -27,6 +27,32 @@ export interface ClaimablePlan {
assigneeUserId: string | null;
}
/**
* 返池归属闸 —— staff 只能退**自己认领的**单(2026-07-29 放开 staff 返池时加)。
*
* 为什么必须有:光把 PLAN_RECYCLE 给 staff,客服 A 就能把客服 B 手里的单退回池、
* 再自己认领 —— 认领闸挡的是"没认领就作业",挡不住"先把别人的单退了"。
*
* 分流依据是 canRecycleOthers(controller 按 PLAN_VIEW_ALL 传入),不是角色字符串:
* 权限模型的真理源是 ROLE_PERMISSIONS,service 层不该再认一次角色。
*
* 无人认领(assignee=null)一律放行:那种"返池"本来就是空操作(recycle 里 hadAssignee=false
* 不记账),拦它只会让前端多一种没意义的报错。
*/
export function assertCanRecycle(
plan: ClaimablePlan,
actorUserId: string | undefined,
canRecycleOthers: boolean,
): void {
if (canRecycleOthers) return;
if (!plan.assigneeUserId) return;
if (plan.assigneeUserId !== actorUserId) {
throw new BizError(ApiCode.PLAN_CLAIMED_BY_OTHER, undefined, {
assigneeUserId: plan.assigneeUserId,
});
}
}
export function assertPlanClaimedBy(plan: ClaimablePlan, userId: string): void {
if (!plan.assigneeUserId) {
throw new BizError(ApiCode.PLAN_NOT_CLAIMED);
......
......@@ -126,7 +126,10 @@ export class PlanController {
@Param('id') id: string,
@Body() _dto: RecyclePlanRequestDto,
) {
await this.plans.recycle(scope, id, user.sub);
// 能不能返**别人**的单,看有没有 PLAN_VIEW_ALL(leader/admin 有,staff 没有)——
// staff 现在也能返池,但只能退自己认领的,校验在 service 里(见 recycle 注释)。
const canRecycleOthers = user.permissions.includes(Permission.PLAN_VIEW_ALL);
await this.plans.recycle(scope, id, user.sub, canRecycleOthers);
return { ok: true as const };
}
......
......@@ -26,6 +26,7 @@ import { PrismaService } from '../../prisma/prisma.service';
import { RedisService } from '../../redis/redis.service';
import { recordPlanEvent, computeHeldSeconds } from './plan-event.recorder';
import { PERSONA_TAG_FILTER_DIMS, parsePersonaTags } from '@pac/types';
import { assertCanRecycle } from './claim-guard';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import type { PlanEngineService } from './engine/plan-engine.service';
import type {
......@@ -48,6 +49,16 @@ import type { ScriptAgentIdentity } from '../ai/calls/draft-plan-script/shared/a
const RECYCLE_TIMEOUT_HOURS = 24; // assignment 后 24h 未结案自动回收(后续接 tenant 配置)
/**
* 「上次医生 / 偏好医生」候选名单的缓存 key —— **导出**给批量重算 CLI 收尾时删。
* key 拼法只此一处,别在 CLI 里再写一遍字符串(写歪了就是"删了个不存在的 key",还静默)。
*/
export const doctorOptionsCacheKey = (hostId: string, tenantId: string): string =>
`pac:plan:doctors:${hostId}:${tenantId}`;
/** 医生名单缓存 TTL —— 10 分钟,理由见 doctorOptions 的注释(缓存久的代价是排查成本) */
export const DOCTOR_OPTIONS_TTL_SECONDS = 10 * 60;
/** 召回池数字概览(queueStats 返回;MCP recall_queue_stats / 工作台 KPI 共用)。 */
export interface PlanQueueStatsResponse {
view: string;
......@@ -214,10 +225,16 @@ export class PlanService {
* 多几个不属于本诊所的名字只是选了筛不出人,而漏名字会让客服以为"这医生没数据"。
* 真正的数据边界由列表查询自己的 scope 保证,这里不是权限闸。
*
* 缓存 6 小时:医生名单变动以月计,而这是个 DISTINCT 聚合,不该每次开筛选面板都跑。
* ⚠️ 缓存 TTL 从 6 小时收到 10 分钟(2026-07-29)。原以为"医生名单变动以月计"所以缓存越久越好,
* 忽略了一件事:**名单是重算产出的,而缓存没有失效钩子**。测试服实测 —— 三个派生列刚上线全是 NULL,
* 第一次打开筛选面板就把 `[]` 缓存了 6 小时;之后重算填了值,客服那边照旧「暂无医生名单」,
* 输入框搜谁都搜不到,还看不出是缓存。缓存久的代价不是"名单旧几小时",是**排查成本**:
* 表现成"这医生搜不到",谁也不会第一时间想到 Redis。
* 10 分钟够挡住"每开一次面板跑一次 DISTINCT",又让任何重算最多 10 分钟内自然可见。
* 批量重算收尾还会主动删这个 key(见 recompute-persona.cli.ts),那条路是即时的。
*/
async doctorOptions(scope: TenantScopeContext): Promise<{ doctors: string[] }> {
const cacheKey = `pac:plan:doctors:${scope.hostId}:${scope.tenantId}`;
const cacheKey = doctorOptionsCacheKey(scope.hostId, scope.tenantId);
const cached = await this.redis.get(cacheKey).catch(() => null);
if (cached) return { doctors: JSON.parse(cached) as string[] };
......@@ -233,7 +250,7 @@ export class PlanService {
ORDER BY name
`;
const doctors = rows.map((r) => r.name);
await this.redis.setEx(cacheKey, JSON.stringify(doctors), 6 * 3600).catch(() => undefined);
await this.redis.setEx(cacheKey, JSON.stringify(doctors), DOCTOR_OPTIONS_TTL_SECONDS).catch(() => undefined);
return { doctors };
}
......@@ -609,7 +626,22 @@ export class PlanService {
// recycle
// ─────────────────────────────────────────────
async recycle(scope: TenantScopeContext, planId: string, actorUserId?: string): Promise<void> {
/**
* 返池 —— 把已认领的 plan 退回召回池(plan 仍 active,可被再次认领)。
*
* ⭐ 归属校验(2026-07-29 staff 开放返池后加的):
* · 有 PLAN_VIEW_ALL(leader/admin)→ 可返**任意人**的单,这就是"组长回收"的原义。
* · 没有(staff)→ **只能返自己认领的**。光给 staff 权限不加这道校验,
* 客服 A 就能把客服 B 手里的单退回池再自己认领,等于绕开认领闸抢单。
* canRecycleOthers 由 controller 从 JWT 权限传入 —— service 不自己去查角色,
* 权限判定的唯一入口仍是 @RequirePermission + 这个布尔。
*/
async recycle(
scope: TenantScopeContext,
planId: string,
actorUserId?: string,
canRecycleOthers = true,
): Promise<void> {
const plan = await this.prisma.followupPlan.findFirst({
where: {
id: planId,
......@@ -628,6 +660,8 @@ export class PlanService {
if (plan.status === 'completed' || plan.status === 'abandoned' || plan.status === 'superseded') {
throw new BadRequestException(`Plan 已终态(${plan.status}),不能返池`);
}
// 归属闸:staff 只能退自己的单(口径与用例见 claim-guard.assertCanRecycle)
assertCanRecycle(plan, actorUserId, canRecycleOthers);
// 只在**确实挂着人**时记账:active 且无 assignee 的"返池"是空操作,记了是噪声
const hadAssignee = plan.assigneeUserId != null;
// ⭐ 在清空 assignedAt 之前算(见 computeHeldSeconds 注释)
......
......@@ -17,16 +17,17 @@ import { recordPlanEvent, computeHeldSeconds } from './plan-event.recorder';
* 1. 「认领」现在要当作「该患者已被客服处理」的口径用于统计。自动回收会把
* assignee_user_id / assigned_at **就地清空**,认领痕迹消失 ——
* 生产两天内回收 32 单,这些"接手了但没继续"的样本全部无法追溯。
* 2. staff 角色**没有 PLAN_RECYCLE 权限**(leader 独占),所以自动回收曾是
* staff 认领后唯一的释放路径。关闭后需注意工单沉淀,见下方「关闭后的代价」。
* 2. 当时 staff 没有 PLAN_RECYCLE 权限(leader 独占),自动回收是 staff 认领后
* 唯一的释放路径 —— **2026-07-29 已放开 staff 自助返池(只能返自己的)**,
* 这条理由随之消解,见下方「关闭后的代价」。
*
* 代码保留而非删除:超时兜底的原始问题(领了不做 → 患者被锁死)依然成立,
* 后续若改成更长超时(如 7d)或加"仅提醒不回收",打开开关即可。
*
* ⚠️ 关闭后的代价(运维需知):
* staff 认领后若不处理,plan 停在 assigned、离开召回池,**staff 自己无法返池**,
* 只有 leader 能手动回收。按当前速率约每天沉淀 30 单。
* 缓解选项(未做,需业务定):给 staff 自助返池权限(只能返自己的),或拉长超时后重开
* staff 认领后若不处理,plan 停在 assigned、离开召回池。原按当前速率约每天沉淀 30 单;
* 2026-07-29 起 staff 可自助返池(只能返自己的),沉淀主要取决于客服会不会主动退,
* 仍需盯。彻底兜底要么拉长超时后重开本调度,要么加"仅提醒不回收"
*
* 行为(开启时):每 10 分钟扫一遍,把"已分配 + 已过 recycleAt"的 plan 退回召回池,
* 并为每一条写 PlanEventLog(event='auto_release'),使归属历史可追溯。
......
import { ApiCode } from '@pac/types';
import { assertPlanClaimedBy } from '../src/modules/plan/claim-guard';
import { assertPlanClaimedBy, assertCanRecycle } from '../src/modules/plan/claim-guard';
import { Permission, ROLE_PERMISSIONS, UserRole } from '@pac/types';
import { BizError } from '../src/common/errors/biz-error';
/**
......@@ -58,3 +59,48 @@ describe('assertPlanClaimedBy — 认领闸', () => {
expect(() => assertPlanClaimedBy({ assigneeUserId: 'u-me-2' }, ME)).toThrow();
});
});
/**
* 返池归属闸 —— 2026-07-29 把 PLAN_RECYCLE 放给 staff 时加的。
*
* 放权限这一步本身是安全的(@RequirePermission 只管"能不能调这个接口"),
* **危险的是没有第二道**:客服 A 拿到 recycle 权限后,能把客服 B 手里的单退回池、
* 再自己认领 —— 认领闸挡的是"没认领就作业",挡不住"先把别人的单退了"。
* 下面四条锁住这道闸,别在重构 recycle 时把它顺手删了。
*/
describe('assertCanRecycle — 返池归属闸', () => {
const ME = 'u-me';
test('⭐ staff 退自己的单 → 放行', () => {
expect(() => assertCanRecycle({ assigneeUserId: ME }, ME, false)).not.toThrow();
});
test('⭐ staff 退别人的单 → PLAN_CLAIMED_BY_OTHER(带上占用人,前端能提示是谁)', () => {
const err = catchErr(() => assertCanRecycle({ assigneeUserId: 'u-other' }, ME, false));
expect(err).toBeInstanceOf(BizError);
expect(err.code).toBe(ApiCode.PLAN_CLAIMED_BY_OTHER);
expect((err.details as { assigneeUserId?: string } | undefined)?.assigneeUserId).toBe('u-other');
});
test('leader(canRecycleOthers)退任意人的单 → 放行,这正是"组长回收"的原义', () => {
expect(() => assertCanRecycle({ assigneeUserId: 'u-other' }, ME, true)).not.toThrow();
});
test('无人认领 → 放行(那种返池本就是空操作,拦它只是多一种没意义的报错)', () => {
expect(() => assertCanRecycle({ assigneeUserId: null }, ME, false)).not.toThrow();
});
});
describe('ROLE_PERMISSIONS — 返池权限矩阵', () => {
test('⭐ staff 有 PLAN_RECYCLE(自助返池),但没有 PLAN_VIEW_ALL —— 后者正是"能不能退别人的"的判据', () => {
const staff = ROLE_PERMISSIONS[UserRole.STAFF];
expect(staff).toContain(Permission.PLAN_RECYCLE);
expect(staff).not.toContain(Permission.PLAN_VIEW_ALL);
});
test('leader 两个都有 → 可退任意人的单', () => {
const leader = ROLE_PERMISSIONS[UserRole.LEADER];
expect(leader).toContain(Permission.PLAN_RECYCLE);
expect(leader).toContain(Permission.PLAN_VIEW_ALL);
});
});
......@@ -51,7 +51,7 @@ export function PatientPickerRail({
}) {
const router = useRouter();
const canViewAll = useHasPermission(Permission.PLAN_VIEW_ALL);
const canRecycle = useHasPermission(Permission.PLAN_RECYCLE); // 「返池」按钮门控(leader/admin)
const canRecycle = useHasPermission(Permission.PLAN_RECYCLE); // 「返池」按钮门控(staff 也有,只能退自己的)
// 默认进「我的」;加载后若「我的」为空,自动回落「召回池」(见下方 effect)。
// 与落地页选默认患者同策略(mine 优先,空则 pool)。
......@@ -699,7 +699,7 @@ function PatientRow({
hidePhone: boolean;
onClick: () => void;
onClaim?: () => void;
/** 已认领 plan 的「返池」回收动作(仅 PLAN_RECYCLE 权限 + status=assigned 时传入) */
/** 已认领 plan 的「返池」回收动作(有 PLAN_RECYCLE 且非终态时传入;staff 只能退自己的,服务端兜底) */
onRecycle?: () => void;
}) {
const breakdown = (p.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority;
......
......@@ -36,9 +36,14 @@ export type Permission = (typeof Permission)[keyof typeof Permission];
export const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = {
[UserRole.STAFF]: [
Permission.PLAN_VIEW_OWN,
/// W3 末:staff 自助认领池里的 plan(分配给自己);分配给他人/回收仍是 leader 权限。
/// W3 末:staff 自助认领池里的 plan(分配给自己);分配给他人仍是 leader 权限。
/// 前端 plansApi.assign(planId, user.sub) 在 pool 视图按钮上调用,即"认领"语义。
Permission.PLAN_ASSIGN,
/// 2026-07-29:staff 也能返池 —— 认错人 / 打不通 / 不该由我跟,自己就该能退回池,
/// 否则只能挂着占位或硬走"关闭机会"(那会污染放弃原因统计)。
/// ⚠️ **只能退自己认领的**:归属校验在 PlanService.recycle 里按"有没有 PLAN_VIEW_ALL"分流,
/// 光给权限不加那道校验 = 客服 A 能把客服 B 的单抢下来。
Permission.PLAN_RECYCLE,
Permission.PLAN_EXECUTE,
Permission.PATIENT_VIEW,
Permission.PERSONA_VIEW,
......@@ -48,7 +53,7 @@ export const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = {
Permission.PLAN_VIEW_OWN,
Permission.PLAN_VIEW_ALL,
Permission.PLAN_ASSIGN, // leader 可分配给任意 staff
Permission.PLAN_RECYCLE, // leader 独占:回收 staff 已认领的 plan
Permission.PLAN_RECYCLE, // leader:可回收**任意人**已认领的 plan(staff 只能退自己的)
Permission.PLAN_EXECUTE,
Permission.PATIENT_VIEW,
Permission.PERSONA_VIEW,
......
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