Commit db7e0dbf by luoqi

fix(越权): propose/refill 的 clinicId 走 body,上一轮按 query 参数扫时漏了

上一个提交补完 /agents、/workload、/plans/matrix 三个 query 参数的读接口,
**紧接着就漏了这条** —— `POST /plans/assignments/propose/refill` 的 clinicId
在请求体里,按 `@Query('clinicId')` 去数根本数不到。

实测比前三个都重:朝阳公园的主管带杭州大厦的 id POST 过来,
拿回了**那家诊所患者的真实姓名**(前三个只到员工名册与人数分布)。

⇒ 闸钉进 `assignment-proposal.service.propose()` 而不是 controller:
   提案是唯一会吐患者名单的读路径,谁调都得拦得住(REST / MCP / 以后的新入口),
    不指望每个 controller 记得加 —— 这一轮漏的就是"记得"。
️ 幂等:MCP 那边已经 resolve 过一次,合法 id 原样返回,再过一次无副作用。

回归同步加一条:锁 service 层那句 resolveClinicId,并禁掉
`const { clinicId } = input` 这种直接解构(那正是漏的写法)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent a0f846f2
Pipeline #3552 failed in 0 seconds
......@@ -13,6 +13,7 @@ import {
import { calcAge } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { resolveClinicId } from '../../common/decorators/resolve-clinic-id';
import { AgentRosterService } from './agent-roster.service';
import { assertCohortCriteria, cohortWhereSql, type CohortCriteria } from './cohort-filter';
......@@ -113,7 +114,20 @@ export class AssignmentProposalService {
},
now: Date = new Date(),
): Promise<AssignmentProposal> {
const { clinicId, potentialTreatment } = input;
/**
* 🔴 **越权闸放在 service 里,⛔ 不是放在 controller**(2026-08-10 实测)。
*
* 同一天先补了 `/agents`、`/workload`、`/plans/matrix` 三个**query 参数**的读接口,
* 结果漏了这条 —— `propose/refill` 的 clinicId 在 **body** 里,按 `@Query('clinicId')`
* 去数根本数不到。实测:朝阳公园的主管带杭州大厦的 id POST 过来,
* 拿回了**那家诊所患者的真实姓名**(比名册更重)。
*
* ⇒ 提案是**唯一**会吐患者名单的读路径,闸必须钉在这一层:
* 谁调都拦得住(REST / MCP / 以后任何新入口),⛔ 不指望每个 controller 记得加。
* ⚠️ 幂等:MCP 那边已经 resolve 过一次,合法 id 原样返回,再过一次没有副作用。
*/
const clinicId = resolveClinicId(scope, input.clinicId);
const { potentialTreatment } = input;
const criteria: CohortCriteria = {
clinicId,
...(potentialTreatment ? { potentialTreatment } : {}),
......
......@@ -89,6 +89,26 @@ describe('MCP 诊所 id —— 不许模型自己编', () => {
expect(src).not.toMatch(/\.matrix\(scope, clinicId\)/);
});
/**
* 🔴🔴 **clinicId 也可能走 body** —— 同一天补完三个 query 接口后**又漏了这条**。
*
* `POST /plans/assignments/propose/refill` 的 clinicId 在请求体里,
* 按 `@Query('clinicId')` 去数根本数不到。实测:朝阳公园的主管带杭州大厦的 id
* POST 过来,拿回了**那家诊所患者的真实姓名** —— 比名册更重。
*
* ⇒ 提案是**唯一**会吐患者名单的读路径,闸钉在 **service** 里(谁调都拦得住),
* ⛔ 不指望每个 controller 记得加 —— 这条测试就是那个"记得"的替代品。
*/
test('🔴🔴 会吐患者名单的提案路径,闸必须在 service 里(body 传的 clinicId 也要拦)', () => {
const src = readFileSync(
join(__dirname, '../src/modules/plan/assignment-proposal.service.ts'),
'utf8',
);
expect(src).toMatch(/const clinicId = resolveClinicId\(scope, input\.clinicId\)/);
// ⛔ 不许再从 input 里直接解构出来用
expect(src).not.toMatch(/const \{ clinicId, potentialTreatment \} = input/);
});
test('CLINIC_ID_SCHEMA 的说明里必须写明「不知道就不要传」', () => {
expect(FACTORY).toMatch(/不知道就不要传/);
expect(FACTORY).toMatch(/get_current_user 返回的 clinicIds/);
......
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