Commit 1e12b729 by luoqi

fix: address main review authorization, facts and delivery findings

parent 4e04aa08
Pipeline #3753 failed in 0 seconds
-- 限流时保存账号级截止时间;NULL 保持现有账号的正常投递行为。
ALTER TABLE app.wecom_accounts ADD COLUMN send_cooldown_until timestamptz(3);
-- 升级前旧中转入队的生命周期可能没有 route;保留排期/租约,只补有真实租户账号归属的待处理事件。
UPDATE app.outbox o
SET route_key = 'acctlife:' || a.id::text
FROM app.wecom_accounts a
WHERE o.task_type = 'inbound.ecom'
AND o.status IN ('pending', 'processing')
AND o.route_key IS NULL
AND o.tenant_id = a.tenant_id
AND o.payload->>'accountId' = a.id::text
AND o.payload#>>'{body,type}' IN ('11026', '11027', '100008');
-- 生命周期的队首必须包括退避中的事件,不能只按 scheduled_at 取到期行。
CREATE INDEX idx_outbox_lifecycle_order ON app.outbox(route_key, created_at, id)
WHERE status IN ('pending', 'processing') AND route_key LIKE 'acctlife:%';
-- 持久关联供应商发送响应与回声,HTTP 先后顺序不再决定人工/AI 身份。
CREATE TABLE app.outbound_echo_receipts (
tenant_id uuid NOT NULL,
account_id uuid NOT NULL,
app_info text NOT NULL,
conversation_id uuid NOT NULL,
send_time timestamptz(3) NOT NULL,
message_id uuid NOT NULL DEFAULT app.uuidv7(),
PRIMARY KEY (account_id, app_info),
CONSTRAINT fk_echo_account FOREIGN KEY (tenant_id, account_id)
REFERENCES app.wecom_accounts(tenant_id, id) ON DELETE RESTRICT,
CONSTRAINT fk_echo_conv FOREIGN KEY (tenant_id, account_id, conversation_id)
REFERENCES app.conversations(tenant_id, account_id, id) ON DELETE RESTRICT
);
CREATE TABLE app.outbound_send_attempts (
id uuid PRIMARY KEY DEFAULT app.uuidv7(),
outbox_id uuid NOT NULL REFERENCES app.outbox(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
account_id uuid NOT NULL,
conversation_id uuid NOT NULL,
actor text NOT NULL CHECK (actor IN ('agent', 'human')),
holding boolean NOT NULL DEFAULT false,
state text NOT NULL DEFAULT 'sending' CHECK (state IN ('sending', 'succeeded', 'rejected', 'uncertain')),
app_info text,
response jsonb,
started_at timestamptz(3) NOT NULL DEFAULT now(),
finished_at timestamptz(3),
expires_at timestamptz(3) NOT NULL,
CONSTRAINT outbound_send_attempts_conversation_fkey
FOREIGN KEY (tenant_id, account_id, conversation_id)
REFERENCES app.conversations(tenant_id, account_id, id) ON DELETE RESTRICT,
CHECK ((state = 'succeeded') = (app_info IS NOT NULL))
);
CREATE INDEX idx_send_attempt_origin ON app.outbound_send_attempts(account_id, app_info);
CREATE INDEX idx_send_attempt_task ON app.outbound_send_attempts(outbox_id, state);
CREATE INDEX idx_send_attempt_window ON app.outbound_send_attempts(account_id, conversation_id, started_at);
ALTER TABLE app.messages DROP CONSTRAINT ck_msg_actor_v;
ALTER TABLE app.messages ADD CONSTRAINT ck_msg_actor_v
CHECK (outbound_actor_type IS NULL OR outbound_actor_type IN ('human', 'agent', 'unknown'));
......@@ -526,6 +526,7 @@ model AuditLog {
model Outbox {
id String @id @default(dbgenerated("app.uuidv7()")) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
sendAttempts OutboundSendAttempt[]
/// 不叫 topic:那是发布订阅黑话,这里装的是任务类型。MIGRATION: app.ext_id
taskType String @map("task_type")
......@@ -829,6 +830,8 @@ model WecomAccount {
/// ⚠️ 落库前必须做单调判断,否则一次顶号会把刚上线的账号写成 offline,
/// 而且 ECom 不会重发事件来纠正它
eventVersion BigInt? @map("event_version")
/// 供应商限流后的账号冷却;新入队消息和人工发送也必须等待。
sendCooldownUntil DateTime? @map("send_cooldown_until") @db.Timestamptz(3)
/// 掉线时 ECom 给的内层业务码。⚠️ 和 reasonType 都要看 —— 单看哪个都判不出严重性
offlineCode Int? @map("offline_code")
/// 人话原因:[11027].reasonTips/msg 或 [100008].spamInfo
......@@ -863,6 +866,7 @@ model WecomAccount {
rooms WecomRoom[]
roomMembers WecomRoomMember[]
conversations Conversation[]
echoReceipts OutboundEchoReceipt[]
contactLinks ContactLink[]
friendAdds FriendAddAttempt[]
......@@ -1127,6 +1131,8 @@ model Conversation {
notes ConversationNote[]
reads ConversationRead[]
messages Message[]
sendAttempts OutboundSendAttempt[]
echoReceipts OutboundEchoReceipt[]
tasks Task[]
llmCalls LlmCall[]
......@@ -1145,11 +1151,50 @@ model Conversation {
// 14. 消息(分区 · append-only)
// ═══════════════════════════════════════════════════════════════
/// 在等待 HTTP 响应前保留回声真实时刻,避免自记 fallback 时间盖掉新患者消息。
model OutboundEchoReceipt {
tenantId String @map("tenant_id") @db.Uuid
accountId String @map("account_id") @db.Uuid
appInfo String @map("app_info")
conversationId String @map("conversation_id") @db.Uuid
sendTime DateTime @map("send_time") @db.Timestamptz(3)
/// 沿用首次回声入队 id,HTTP 后自记也不会越过同秒的新患者消息。
messageId String @default(dbgenerated("app.uuidv7()")) @map("message_id") @db.Uuid
account WecomAccount @relation(fields: [tenantId, accountId], references: [tenantId, id], onDelete: Restrict, onUpdate: NoAction, map: "fk_echo_account")
conversation Conversation @relation(fields: [tenantId, accountId, conversationId], references: [tenantId, accountId, id], onDelete: Restrict, onUpdate: NoAction, map: "fk_echo_conv")
@@id([accountId, appInfo])
@@map("outbound_echo_receipts")
@@schema("app")
}
model OutboundSendAttempt {
id String @id @default(dbgenerated("app.uuidv7()")) @db.Uuid
outboxId String @map("outbox_id") @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
accountId String @map("account_id") @db.Uuid
conversationId String @map("conversation_id") @db.Uuid
actor String
holding Boolean @default(false)
/// sending | succeeded | rejected | uncertain;不把超时误当作明确拒绝。
state String @default("sending")
appInfo String? @map("app_info")
response Json?
startedAt DateTime @default(now()) @map("started_at") @db.Timestamptz(3)
finishedAt DateTime? @map("finished_at") @db.Timestamptz(3)
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
outbox Outbox @relation(fields: [outboxId], references: [id], onDelete: Cascade, onUpdate: NoAction)
conversation Conversation @relation(fields: [tenantId, accountId, conversationId], references: [tenantId, accountId, id], onDelete: Restrict, onUpdate: NoAction, map: "outbound_send_attempts_conversation_fkey")
@@index([accountId, appInfo], map: "idx_send_attempt_origin")
@@index([outboxId, state], map: "idx_send_attempt_task")
@@index([accountId, conversationId, startedAt], map: "idx_send_attempt_window")
@@map("outbound_send_attempts")
@@schema("app")
}
/// MIGRATION S6: PARTITION BY RANGE (send_time),按月预建 + DEFAULT 兜底
/// MIGRATION ck_msg_direction / ck_msg_outsrc / ck_msg_outsrc_v / ck_msg_actor / ck_msg_actor_v
///
/// ⚠️ append-only。**唯一允许的原地更新是 revoked_at**(投影可重建)。
/// 更新它必须带 send_time 谓词,否则分区裁剪失效 —— 实测 18 个扫描节点 vs 1 个
/// 消息正文 append-only;允许更新撤回状态和被供应商响应举证的发送来源。
model Message {
id String @default(dbgenerated("app.uuidv7()")) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
......@@ -1179,7 +1224,7 @@ model Message {
/// (echo 不保证一定回来,所以自己发的也要自记一份)
outboundSource String? @map("outbound_source")
/// ⭐ 谁发的 —— 通道层面 human 和 agent 完全一样,只有这一列能区分
/// MIGRATION ck_msg_actor_v: human | agent
/// MIGRATION ck_msg_actor_v: human | agent | unknown(发送响应丢失,不能证明人工)
outboundActorType String? @map("outbound_actor_type")
textContent String? @map("text_content")
......
......@@ -520,7 +520,7 @@ export function toContextMessages(
/** 我方这条是同事(人)发的 —— 见 `ContextMessage.colleague`。⚠️ 没带这一列(老调用方)⇒ ⛔ 不算 */
function byColleague(m: RawMessage): boolean {
return m.direction === 'outbound' && m.outboundActorType !== undefined && m.outboundActorType !== 'agent';
return m.direction === 'outbound' && m.outboundActorType !== undefined && m.outboundActorType !== 'agent' && m.outboundActorType !== 'unknown';
}
/**
......
......@@ -1315,7 +1315,7 @@ export class ReceptionService {
*
* ⚠️ 判据是 `messages`(真发出去的),⛔ 不是 outbox(只是打算发)——
* 队列里躺着的可能正是 AI 上一轮的话,拿它当"已经回过了"就是自证。
* ⚠️ `outbound_actor_type <> 'agent'` 而⛔ 不是 `= 'human'`:这一列是后加的,
* 显式 unknown 不算人工;⛔ 不能只用 `= 'human'`:这一列是后加的,
* 历史行可能是 NULL。⇒ 认 NULL 会让 AI **少说**一句,⛔ 不会多说 —— 选更严的那边。
* ⚠️ 带 `send_time >=` 下界 ⇒ 走 `idx_msg_conv` 且能裁剪分区
* (messages 按 send_time 分区,⛔ 不带下界会扫遍每一个分区)。
......@@ -1329,7 +1329,7 @@ export class ReceptionService {
WHERE conversation_id = ${conversationId}::uuid
AND send_time >= ${input.sendTime}
AND direction = 'outbound'
AND coalesce(outbound_actor_type, 'human') <> 'agent'
AND coalesce(outbound_actor_type, 'human') NOT IN ('agent', 'unknown')
AND kind <> 'system' AND revoked_at IS NULL`;
return Number(rows[0]?.n ?? 0) > 0;
}
......
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHash } from 'node:crypto';
import { dedupKey, isLoginEvent } from '@pac/ecom-client';
import { dedupKey, isLoginEvent, isAccountLifecycleEvent } from '@pac/ecom-client';
import { PrismaService } from '../../prisma/prisma.service';
import { TASK_TYPE } from '../outbox/outbox.types';
......@@ -204,7 +204,8 @@ export class RelaySubscriberService implements OnModuleInit, OnModuleDestroy {
await tx.$executeRaw`
INSERT INTO app.outbox (tenant_id, task_type, route_key, payload, dedup_key, payload_hash)
VALUES (
${account.tenantId}::uuid, ${TASK_TYPE.INBOUND_ECOM}, NULL,
${account.tenantId}::uuid, ${TASK_TYPE.INBOUND_ECOM},
${isAccountLifecycleEvent(eventType ?? undefined) ? `acctlife:${account.id}` : null},
${JSON.stringify({ accountId: account.id, accountVid: account.vid, body })}::jsonb,
${dedup}, ${dedup ? sha256(JSON.stringify(body ?? null)) : null})
ON CONFLICT (tenant_id, task_type, dedup_key) WHERE dedup_key IS NOT NULL
......
......@@ -273,7 +273,18 @@ export class IntegrationAccessGuard implements CanActivate {
.getRequest<Request & { user?: AuthUser; hostCapability?: string }>();
const user = req.user;
if (!user?.hostScope) return true;
const path = req.path.replace(/^\/api(?=\/)/, '');
// 按框架实际匹配的路由判定能力;资源 ID 必须与控制器收到的已解码参数一致。
// 原始 req.path 仍含百分号编码,用 UUID 正则筛它会漏掉实际有效的资源请求。
const routePath: unknown = req.route?.path;
if (typeof routePath !== 'string')
throw new BizError(12806, '无法核验请求资源');
const path = routePath.replace(/^\/api(?=\/)/, '');
const resourceParam = (name: string): string => {
const value = req.params?.[name];
if (typeof value !== 'string' || !value)
throw new BizError(12806, '无法核验请求资源');
return value;
};
const cap = this.access.capability(path, req.method);
if (!cap || !user.hostScope.capabilities.includes(cap))
throw new BizError(12805, '宿主授权不包含此功能');
......@@ -291,16 +302,16 @@ export class IntegrationAccessGuard implements CanActivate {
if (needsWorkbench && !user.hostScope.modules.includes('workbench'))
throw new BizError(12811, '入口模块未开通');
req.hostCapability = cap;
const conversation = /^\/conversations\/([0-9a-f-]{36})(?:\/|$)/i.exec(path);
if (conversation) await this.access.assertConversation(user, conversation[1]!);
const job = /^\/conversations\/jobs\/([0-9a-f-]{36})/i.exec(path);
if (job) await this.access.assertJob(user, job[1]!);
const task = /^\/agents\/appointment-confirm\/tasks\/([0-9a-f-]{36})/i.exec(path);
if (task) await this.access.assertTask(user, task[1]!);
const contact = /^\/contacts\/([0-9a-f-]{36})/i.exec(path);
if (contact) await this.access.assertContact(user, contact[1]!);
const patient = /^\/patients\/([0-9a-f-]{36})/i.exec(path);
if (patient) await this.access.assertPatient(user, patient[1]!);
if (/^\/conversations\/:id(?:\/|$)/.test(path))
await this.access.assertConversation(user, resourceParam('id'));
if (/^\/conversations\/jobs\/:jobId(?:\/|$)/.test(path))
await this.access.assertJob(user, resourceParam('jobId'));
if (/^\/agents\/appointment-confirm\/tasks\/:taskId(?:\/|$)/.test(path))
await this.access.assertTask(user, resourceParam('taskId'));
const contact = /^\/contacts\/:id(?:\/|$)/.test(path);
if (contact) await this.access.assertContact(user, resourceParam('id'));
if (/^\/patients\/:id(?:\/|$)/.test(path))
await this.access.assertPatient(user, resourceParam('id'));
if (contact && path.endsWith('/patient') && typeof req.body?.patientId === 'string')
await this.access.assertPatient(user, req.body.patientId);
if (
......@@ -323,7 +334,7 @@ export class IntegrationAccessGuard implements CanActivate {
req.hostCapability = action;
}
if (/^\/(attachments|avatars|stickers)\//.test(path) && ['GET', 'HEAD'].includes(req.method))
await this.access.assertMedia(user, path);
await this.access.assertMedia(user, req.path.replace(/^\/api(?=\/)/, ''));
return true;
}
}
......@@ -2,6 +2,7 @@ import { hostingAllowed, HOSTING_UNAVAILABLE } from '../hosting/hosting-seat.ser
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AI_PAUSE_SCOPE_ZH, aiSilencedQuery } from '../agent/shared/ai-pause';
import { cooldownWait } from './rate-limit';
/** 发起方。⚠️ 决定过哪几道闸 —— 两类风险对应两个闸([12 §12.2])。 */
export type Actor = 'human' | 'agent';
......@@ -11,8 +12,9 @@ export type GateVerdict =
| {
pass: false;
/** ⚠️ `account_online` **不是开关** —— 它走 hold,其余走作废。见类注释 */
gate: 'account_missing' | 'account_online' | 'ai_paused' | 'hosting_expired';
gate: 'account_missing' | 'account_online' | 'account_rate_limited' | 'ai_paused' | 'hosting_expired';
reason: string;
retryAfterMs?: number;
};
/**
......@@ -62,7 +64,7 @@ export class GateService {
/**
* 投递前过闸。
*
* @param actor `human` **一律放行**(仍校验账号存在、有效托管席位和在线状态);`agent` 过四层开关。
* @param actor human 不受 AI 开关限制;仍校验账号、席位、在线和供应商限流冷却。
*/
async check(opts: {
tenantId: string;
......@@ -73,7 +75,7 @@ export class GateService {
}): Promise<GateVerdict> {
const account = await this.prisma.wecomAccount.findFirst({
where: { id: opts.accountId, tenantId: opts.tenantId },
select: { status: true, vid: true, offlineReason: true },
select: { status: true, vid: true, offlineReason: true, sendCooldownUntil: true },
});
// ⚠️ 读不到 = 拒。⛔ 这一条和"没有行=放行"是两件事,见类注释
if (!account) {
......@@ -99,6 +101,9 @@ export class GateService {
};
}
const wait = cooldownWait(account.sendCooldownUntil);
if (wait > 0) return { pass: false, gate: 'account_rate_limited', reason: '企业微信号限流冷却中', retryAfterMs: wait };
// ⭐ 人工到此为止 —— 开关只管 Agent
if (opts.actor === 'human') return { pass: true };
......
import { Injectable, Logger } from '@nestjs/common';
import { isHumanActor, lockConversation, projectionOrder, reconcileSelfMessage, trackedSend } from './send-origin';
import { weightedLength, splitWxFaces } from '@pac/ecom-client';
import { PrismaService } from '../../prisma/prisma.service';
import { BizError } from '../../common/errors/biz-error';
......@@ -124,6 +125,9 @@ export interface OutboundLocationPayload {
* 会话投影也推进了、界面上气泡也出来了 —— 而患者那边什么都没收到。
*/
interface SendResult {
/** 我方持久发送尝试分配的稳定 UUIDv7,非供应商字段。 */
recordMessageId?: string;
recordStartedAt?: number;
/** ⭐ 内层业务码。0 才是真的发出去了 */
code?: number;
msg?: string;
......@@ -209,8 +213,8 @@ export class OutboundEcomHandler implements OutboxHandler {
* ⛔ **`account_online` 例外**:号不在线**不是开关**(是"现在发不出去"),
* 它照旧 hold —— 号回来了这条还该发。
*/
if (verdict.gate === 'account_online') {
return { ok: false, kind: 'hold', reason: `${verdict.gate} / ${verdict.reason}` };
if (verdict.gate === 'account_online' || verdict.gate === 'account_rate_limited') {
return { ok: false, kind: 'hold', reason: `${verdict.gate} / ${verdict.reason}`, retryAfterMs: verdict.retryAfterMs };
}
return { ok: true, status: 'canceled', reason: `开关:${verdict.reason}` };
}
......@@ -255,7 +259,7 @@ export class OutboundEcomHandler implements OutboxHandler {
let sent: SendResult;
try {
sent = await this.ecom.call<SendResult>(
sent = await trackedSend(this.prisma, task, p, () => this.ecom.call<SendResult>(
11029,
{ userId: p.accountVid, deviceId: acct.deviceId },
{
......@@ -267,7 +271,7 @@ export class OutboundEcomHandler implements OutboxHandler {
*/
contentList: splitWxFaces(p.contentList),
},
);
));
} catch (e) {
if (e instanceof BizError) {
/**
......@@ -415,13 +419,14 @@ export class OutboundEcomHandler implements OutboxHandler {
weSpokeSince: msgs.some((m) => m.direction === 'outbound' && !m.is_anchor),
/**
* ⭐⭐⭐ **人**在这之后有没有真发出去过消息([17 §6])。
* ⚠️ 判据是 `outbound_actor_type <> 'agent'`,⛔ 不是 `= 'human'` ——
* 显式 unknown 不算人工;NULL 历史行仍按原来的人工语义处理。
* ⚠️ ⛔ 不能只用 `= 'human'` ——
* 人从**自己客户端**发的那条是回声落库的,那条路写的就是 `'human'`;
* 但历史行可能是 NULL(这一列是后加的)⇒ 用 `= 'human'` 会把它们漏掉,
* 而漏掉的方向是**多发一句**,⛔ 不是少发 —— 仍然选更严的那边。
*/
humanSpokeSince: msgs.some(
(m) => m.direction === 'outbound' && !m.is_anchor && m.actor !== 'agent',
(m) => m.direction === 'outbound' && !m.is_anchor && isHumanActor(m.actor),
),
// ⭐ 被答那句撤回了没有 —— ⚠️ 上面那次查询滤掉了撤回的,这里单独问一句(同一条索引、一行)
anchorRevoked: await this.anchorRevoked(p.conversationId, p.answersAppInfo, answersAt),
......@@ -543,17 +548,20 @@ export class OutboundEcomHandler implements OutboxHandler {
p: OutboundTextPayload,
sent: SendResult,
): Promise<void> {
const sendTime = new Date((sent.sendTime ?? Math.floor(Date.now() / 1000)) * 1000);
const fallbackTime = new Date((sent.sendTime ?? Math.floor(Date.now() / 1000)) * 1000);
const text = p.contentList.join('');
await this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`
await lockConversation(tx, p.conversationId);
const { sendTime, existing, messageId } = await reconcileSelfMessage(tx, p.accountId, sent.appInfo!, p.actor, !!p.holding, fallbackTime, sent.recordMessageId);
const { time: projectionTime, id: projectionId } = projectionOrder(sendTime, messageId, sent);
if (!existing) await tx.$executeRaw`
INSERT INTO app.messages (
tenant_id, account_id, conversation_id, app_info, server_id, send_time,
id, tenant_id, account_id, conversation_id, app_info, server_id, send_time,
content_type, kind, sender_vid, direction, outbound_source, outbound_actor_type,
text_content, raw)
VALUES (
${tenantId}::uuid, ${p.accountId}::uuid, ${p.conversationId}::uuid, ${sent.appInfo},
${messageId}::uuid, ${tenantId}::uuid, ${p.accountId}::uuid, ${p.conversationId}::uuid, ${sent.appInfo},
${sent.serverId ? BigInt(sent.serverId) : null}, ${sendTime},
${sent.contentType ?? 0}, 'text', ${p.accountVid}, 'outbound',
-- ⭐ self_recorded 不是 echo:这条是我们**自己记下来**的,不是回调推回来的。
......@@ -578,7 +586,12 @@ export class OutboundEcomHandler implements OutboxHandler {
-- 严格小于会让"我主动发起的第一句"不推进投影(同 inbound 侧的说明)。
-- 重放在这里也不怕:重写一遍的值和原来一样。
WHERE id = ${p.conversationId}::uuid
AND last_msg_at <= ${sendTime}`;
AND last_msg_at <= ${sendTime}
AND NOT EXISTS (
SELECT 1 FROM app.messages newer
WHERE newer.conversation_id = ${p.conversationId}::uuid AND newer.direction = 'inbound'
AND (newer.send_time, newer.id) > (${projectionTime}, ${projectionId}::uuid)
)`;
/**
* ⭐⭐ **AI 自己发出去了 ⇒ 那份草稿不再是「提议」。**
......
import { Injectable, Logger } from '@nestjs/common';
import { lockConversation, projectionOrder, reconcileSelfMessage, trackedSend } from './send-origin';
import { locationText } from '@pac/ecom-client';
import { PrismaService } from '../../prisma/prisma.service';
import { BizError } from '../../common/errors/biz-error';
......@@ -13,6 +14,8 @@ import type { OutboundLocationPayload } from './outbound-ecom.handler';
/** `[161017]` 的响应 —— 和其它发送接口同一个 `SendMsgResultVO` */
interface SendResult {
recordMessageId?: string;
recordStartedAt?: number;
code?: number;
msg?: string;
appInfo?: string;
......@@ -72,8 +75,8 @@ export class OutboundLocationHandler implements OutboxHandler {
conversationId: p.conversationId,
});
if (!verdict.pass) {
if (verdict.gate === 'account_online') {
return { ok: false, kind: 'hold', reason: `${verdict.gate} / ${verdict.reason}` };
if (verdict.gate === 'account_online' || verdict.gate === 'account_rate_limited') {
return { ok: false, kind: 'hold', reason: `${verdict.gate} / ${verdict.reason}`, retryAfterMs: verdict.retryAfterMs };
}
await this.releaseDedup(task.id);
return { ok: true, status: 'canceled', reason: `开关:${verdict.reason}` };
......@@ -91,7 +94,7 @@ export class OutboundLocationHandler implements OutboxHandler {
let sent: SendResult;
try {
sent = await this.ecom.call<SendResult>(
sent = await trackedSend(this.prisma, task, p, () => this.ecom.call<SendResult>(
161017,
{ userId: p.accountVid, deviceId: acct.deviceId },
{
......@@ -103,7 +106,7 @@ export class OutboundLocationHandler implements OutboxHandler {
name: p.name,
address: p.address,
},
);
));
} catch (e) {
if (e instanceof BizError) {
if (e.code === AiApiCode.ECOM_RATE_LIMITED) {
......@@ -153,16 +156,19 @@ export class OutboundLocationHandler implements OutboxHandler {
/** 自己发出去的位置卡片落 messages —— ⚠️ 正文用入站同一个写法,AI 和时间线都读得懂 */
private async recordSelfSent(tenantId: string, p: OutboundLocationPayload, sent: SendResult): Promise<void> {
const sendTime = new Date((sent.sendTime ?? Math.floor(Date.now() / 1000)) * 1000);
const fallbackTime = new Date((sent.sendTime ?? Math.floor(Date.now() / 1000)) * 1000);
const text = locationText(p.name, p.address);
await this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`
await lockConversation(tx, p.conversationId);
const { sendTime, existing, messageId } = await reconcileSelfMessage(tx, p.accountId, sent.appInfo!, p.actor, false, fallbackTime, sent.recordMessageId);
const { time: projectionTime, id: projectionId } = projectionOrder(sendTime, messageId, sent);
if (!existing) await tx.$executeRaw`
INSERT INTO app.messages (
tenant_id, account_id, conversation_id, app_info, server_id, send_time,
id, tenant_id, account_id, conversation_id, app_info, server_id, send_time,
content_type, kind, sender_vid, direction, outbound_source, outbound_actor_type,
text_content, raw)
VALUES (
${tenantId}::uuid, ${p.accountId}::uuid, ${p.conversationId}::uuid, ${sent.appInfo},
${messageId}::uuid, ${tenantId}::uuid, ${p.accountId}::uuid, ${p.conversationId}::uuid, ${sent.appInfo},
${sent.serverId ? BigInt(sent.serverId) : null}, ${sendTime},
${sent.contentType ?? 6}, 'location',
${p.accountVid}, 'outbound', 'self_recorded', ${p.actor},
......@@ -177,7 +183,12 @@ export class OutboundLocationHandler implements OutboxHandler {
last_reply_actor_type = ${p.actor},
pending_mention_user_id = NULL
WHERE id = ${p.conversationId}::uuid
AND last_msg_at <= ${sendTime}`;
AND last_msg_at <= ${sendTime}
AND NOT EXISTS (
SELECT 1 FROM app.messages newer
WHERE newer.conversation_id = ${p.conversationId}::uuid AND newer.direction = 'inbound'
AND (newer.send_time, newer.id) > (${projectionTime}, ${projectionId}::uuid)
)`;
});
}
}
import { Injectable, Logger } from '@nestjs/common';
import { lockConversation, projectionOrder, reconcileSelfMessage, trackedSend } from './send-origin';
import { PrismaService } from '../../prisma/prisma.service';
import { BizError } from '../../common/errors/biz-error';
import { AiApiCode } from '../../common/errors/api-codes';
......@@ -15,6 +16,8 @@ import type { OutboundMediaPayload } from './outbound-ecom.handler';
/** `[11030]`/`[11031]` 的响应 —— 和 `[11029]` 同构:**双层错误码**。 */
interface SendResult {
recordMessageId?: string;
recordStartedAt?: number;
code?: number;
msg?: string;
appInfo?: string;
......@@ -83,8 +86,8 @@ export class OutboundMediaHandler implements OutboxHandler {
* ⛔ **`account_online` 例外**:号不在线**不是开关**(是"现在发不出去"),
* 它照旧 hold —— 号回来了这条还该发。
*/
if (verdict.gate === 'account_online') {
return { ok: false, kind: 'hold', reason: `${verdict.gate} / ${verdict.reason}` };
if (verdict.gate === 'account_online' || verdict.gate === 'account_rate_limited') {
return { ok: false, kind: 'hold', reason: `${verdict.gate} / ${verdict.reason}`, retryAfterMs: verdict.retryAfterMs };
}
return { ok: true, status: 'canceled', reason: `开关:${verdict.reason}` };
}
......@@ -112,7 +115,7 @@ export class OutboundMediaHandler implements OutboxHandler {
let sent: SendResult;
try {
sent = await this.ecom.call<SendResult>(
sent = await trackedSend(this.prisma, task, p, () => this.ecom.call<SendResult>(
isImage ? 11030 : 11031,
{ userId: p.accountVid, deviceId: acct.deviceId },
isImage
......@@ -124,7 +127,7 @@ export class OutboundMediaHandler implements OutboxHandler {
// ⚠️ [11031] required。患者在微信里看到的就是这个名字
fileName: p.fileName || '文件',
},
);
));
} catch (e) {
if (e instanceof BizError) {
// ⭐⭐ 限流要停下来(同文本那条)—— ⚠️ 和文本**共用一条队列**(route_key=acct:<id>),
......@@ -184,15 +187,18 @@ export class OutboundMediaHandler implements OutboxHandler {
p: OutboundMediaPayload,
sent: SendResult,
): Promise<void> {
const sendTime = new Date((sent.sendTime ?? Math.floor(Date.now() / 1000)) * 1000);
const fallbackTime = new Date((sent.sendTime ?? Math.floor(Date.now() / 1000)) * 1000);
await this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`
await lockConversation(tx, p.conversationId);
const { sendTime, existing, messageId: stableId } = await reconcileSelfMessage(tx, p.accountId, sent.appInfo!, p.actor, false, fallbackTime, sent.recordMessageId);
const { time: projectionTime, id: projectionId } = projectionOrder(sendTime, stableId, sent);
if (!existing) await tx.$executeRaw`
INSERT INTO app.messages (
tenant_id, account_id, conversation_id, app_info, server_id, send_time,
id, tenant_id, account_id, conversation_id, app_info, server_id, send_time,
content_type, kind, sender_vid, direction, outbound_source, outbound_actor_type,
text_content, raw)
VALUES (
${tenantId}::uuid, ${p.accountId}::uuid, ${p.conversationId}::uuid, ${sent.appInfo},
${stableId}::uuid, ${tenantId}::uuid, ${p.accountId}::uuid, ${p.conversationId}::uuid, ${sent.appInfo},
${sent.serverId ? BigInt(sent.serverId) : null}, ${sendTime},
${sent.contentType ?? (p.kind === 'image' ? 7 : 15)},
${p.kind === 'image' ? 'image' : 'file'},
......@@ -238,7 +244,12 @@ export class OutboundMediaHandler implements OutboxHandler {
pending_mention_user_id = NULL
-- ⚠️ 是 <= 不是 < :新会话的 last_msg_at 就等于第一条消息的时刻(同 inbound 侧)
WHERE id = ${p.conversationId}::uuid
AND last_msg_at <= ${sendTime}`;
AND last_msg_at <= ${sendTime}
AND NOT EXISTS (
SELECT 1 FROM app.messages newer
WHERE newer.conversation_id = ${p.conversationId}::uuid AND newer.direction = 'inbound'
AND (newer.send_time, newer.id) > (${projectionTime}, ${projectionId}::uuid)
)`;
/**
* ⚠️ 这里**没有**文字通道那边的「清 ai_draft」—— 不是漏了。
......
......@@ -6,7 +6,7 @@ import { BizError } from '../../common/errors/biz-error';
import { AiApiCode } from '../../common/errors/api-codes';
import { AlertService } from '../../common/alerting/alert.service';
import { EcomService, isRateLimited } from '../channel/ecom.service';
import { holdForRateLimit } from './rate-limit';
import { holdForRateLimit, cooldownWait } from './rate-limit';
import {
TASK_TYPE,
type ClaimedTask,
......@@ -88,7 +88,7 @@ export class OutboundNotifyHandler implements OutboxHandler {
*/
const acct = await this.prisma.wecomAccount.findFirst({
where: { id: p.accountId, tenantId: task.tenantId },
select: { status: true, deviceId: true, purpose: true, offlineReason: true },
select: { status: true, deviceId: true, purpose: true, offlineReason: true, sendCooldownUntil: true },
});
if (!acct) return { ok: false, kind: 'dead', error: '找不到提醒号' };
if (!await hostingAllowed(this.prisma, task.tenantId, p.accountId)) return { ok: true, status: 'canceled', reason: HOSTING_UNAVAILABLE };
......@@ -107,6 +107,8 @@ export class OutboundNotifyHandler implements OutboxHandler {
retryAfterMs: 60_000,
};
}
const wait = cooldownWait(acct.sendCooldownUntil);
if (wait > 0) return { ok: false, kind: 'hold', reason: '提醒号限流冷却中', retryAfterMs: wait };
/**
* ⭐⭐ **限流要停整条队列,⛔ 不是接着重试** —— 和发给患者那条同一个处置(`outbound-ecom.handler`)。
......
......@@ -17,6 +17,11 @@ import type { HandlerResult } from '../outbox/outbox.types';
*/
const COOL_DOWN_MS = 5 * 60_000;
/** 投递时按账号检查,不能只延迟限流当时已有的队列行。 */
export function cooldownWait(until: Date | null | undefined, now = Date.now()): number {
return until ? Math.max(0, until.getTime() - now) : 0;
}
/**
* 撞到限流时的处置 —— ⭐ **停整个号的队列**,⛔ 不是只退这一条。
*
......@@ -45,8 +50,14 @@ export async function holdForRateLimit(
let paused = 0;
try {
paused = await deps.prisma.$executeRaw`
WITH cooled AS (
UPDATE app.wecom_accounts
SET send_cooldown_until = GREATEST(coalesce(send_cooldown_until, ${until}), ${until})
WHERE id = ${input.accountId}::uuid
RETURNING send_cooldown_until
)
UPDATE app.outbox
SET scheduled_at = GREATEST(scheduled_at, ${until})
SET scheduled_at = GREATEST(scheduled_at, coalesce((SELECT send_cooldown_until FROM cooled), ${until}))
WHERE route_key = ${routeKey}
AND status = 'pending'`;
} catch (e) {
......
import { BizError } from '../../common/errors/biz-error';
import { AiApiCode } from '../../common/errors/api-codes';
import type { PrismaService } from '../../prisma/prisma.service';
import type { ClaimedTask } from '../outbox/outbox.types';
type SqlClient = Pick<PrismaService, '$queryRaw' | '$executeRaw'>;
type SendResponse = { code?: number; appInfo?: string; sendTime?: number; msgData?: unknown; recordMessageId?: string; recordStartedAt?: number };
export type EchoOrigin = { actor: 'agent' | 'human' | 'unknown'; holding: boolean; pending: boolean; projectionId?: string; projectionTime?: Date };
/** 请求 traceId 没有回声关联保证;只凭响应 appInfo 举证,⛔ 不按正文猜来源。 */
export async function trackedSend<T extends SendResponse>(
prisma: PrismaService,
task: ClaimedTask,
p: { accountId: string; conversationId: string; actor: string; holding?: boolean },
send: () => Promise<T>,
): Promise<T> {
// 已取得供应商成功响应、但消息落库失败时,重试只补记账,⛔ 不再发一次。
const recorded = await prisma.$queryRaw<{ response: T }[]>`
SELECT response FROM app.outbound_send_attempts
WHERE outbox_id = ${task.id}::uuid AND state = 'succeeded'
ORDER BY started_at DESC LIMIT 1`;
if (recorded[0]) return recorded[0].response;
const attempts = await prisma.$queryRaw<{ id: string; started_at: Date }[]>`
INSERT INTO app.outbound_send_attempts
(outbox_id, tenant_id, account_id, conversation_id, actor, holding, expires_at)
SELECT id, tenant_id, ${p.accountId}::uuid, ${p.conversationId}::uuid,
${p.actor}, ${!!p.holding}, coalesce(lease_until, now() + interval '5 minutes')
FROM app.outbox WHERE id = ${task.id}::uuid RETURNING id, started_at`;
if (!attempts[0]) throw new Error('发送任务已不存在,未调用供应商');
const id = attempts[0].id;
const startedAt = Math.floor(attempts[0].started_at.getTime() / 1000);
let response: T;
try {
response = await send();
} catch (e) {
// 网络超时不证明没有发出;明确供应商业务拒绝才排除自身发送。
const rejected = e instanceof BizError && e.code !== AiApiCode.ECOM_UNREACHABLE;
await prisma.$executeRaw`
UPDATE app.outbound_send_attempts SET state = ${rejected ? 'rejected' : 'uncertain'}, finished_at = now()
WHERE id = ${id}::uuid`;
throw e;
}
const rejected = response?.code != null && Number(response.code) !== 0;
const state = rejected ? 'rejected' : response?.appInfo ? 'succeeded' : 'uncertain';
if (state === 'succeeded') response = { ...response, recordMessageId: id, recordStartedAt: startedAt };
if (state === 'succeeded' && !response.sendTime) {
const msgTime = Number((response.msgData as { sendTime?: unknown } | undefined)?.sendTime);
// 完全没有供应商时间时取请求起点作保守近似,⛔ 不用响应完成时刻清掉中途的新患者等待。
response = { ...response, sendTime: Number.isFinite(msgTime) && msgTime > 0 ? msgTime : startedAt,
...(Number.isFinite(msgTime) && msgTime > 0 ? {} : { sendTimeApproximate: true }) };
}
await prisma.$executeRaw`
UPDATE app.outbound_send_attempts
SET state = ${state}, app_info = ${state === 'succeeded' ? response.appInfo : null},
response = ${JSON.stringify(response ?? null)}::jsonb, finished_at = now()
WHERE id = ${id}::uuid`;
return response;
}
export async function rememberEcho(
prisma: PrismaService, tenantId: string, accountId: string, conversationId: string, appInfo: string, sendTime: Date, messageId: string,
): Promise<string> {
await prisma.$executeRaw`
INSERT INTO app.outbound_echo_receipts(tenant_id, account_id, app_info, conversation_id, send_time, message_id)
VALUES (${tenantId}::uuid, ${accountId}::uuid, ${appInfo}, ${conversationId}::uuid, ${sendTime}, ${messageId}::uuid)
ON CONFLICT (account_id, app_info) DO NOTHING`;
const receipts = await prisma.$queryRaw<{ message_id: string }[]>`
SELECT message_id FROM app.outbound_echo_receipts
WHERE tenant_id = ${tenantId}::uuid AND account_id = ${accountId}::uuid
AND conversation_id = ${conversationId}::uuid AND app_info = ${appInfo}`;
if (!receipts[0]) throw new Error('回声归属不一致,未写会话消息');
return receipts[0].message_id;
}
/** 同会话写消息与投影串行;跨账号/会话仍可并发。 */
export async function lockConversation(tx: SqlClient, conversationId: string): Promise<void> {
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(${conversationId}, 0))::text`;
}
/** 时间相同仍按稳定入队 UUIDv7 排序,⛔ 后补的自记/回声不跨过患者的新一句。 */
export async function hasNewerInbound(tx: SqlClient, conversationId: string, sendTime: Date, messageId: string): Promise<boolean> {
const rows = await tx.$queryRaw<{ one: number }[]>`
SELECT 1 AS one FROM app.messages
WHERE conversation_id = ${conversationId}::uuid AND direction = 'inbound'
AND (send_time, id) > (${sendTime}, ${messageId}::uuid) LIMIT 1`;
return rows.length > 0;
}
/** 展示保留真实发送时间;等待投影取消息与请求起点中较早的边界。 */
export function projectionOrder(sendTime: Date, messageId: string, response: Pick<SendResponse, 'recordStartedAt' | 'recordMessageId'>): { time: Date; id: string } {
if (response.recordStartedAt == null || !response.recordMessageId) return { time: sendTime, id: messageId };
const startedAt = new Date(response.recordStartedAt * 1000);
return startedAt < sendTime || (startedAt.getTime() === sendTime.getTime() && response.recordMessageId < messageId)
? { time: startedAt, id: response.recordMessageId } : { time: sendTime, id: messageId };
}
export async function echoOrigin(
prisma: PrismaService, accountId: string, conversationId: string, appInfo: string,
sendTime: Date, inboundTaskId: string,
): Promise<EchoOrigin> {
// 以回调入队时刻/供应商消息时刻与发送窗口的交集判断“可能来自自己”。
// 秒级 send_time 覆盖起始秒;窗口终点固定,失败一次⛔ 不能让以后的人工永久变未知。
const attempts = await prisma.$queryRaw<EchoOrigin[]>`
SELECT actor, holding, false AS pending, id AS "projectionId", date_trunc('second', started_at) AS "projectionTime"
FROM app.outbound_send_attempts
WHERE account_id = ${accountId}::uuid AND app_info = ${appInfo} AND state = 'succeeded'
UNION ALL
SELECT 'unknown' AS actor, false AS holding, (a.state = 'sending' AND a.expires_at > now()) AS pending,
NULL::uuid AS "projectionId", NULL::timestamptz AS "projectionTime"
FROM app.outbound_send_attempts a
JOIN app.outbox incoming ON incoming.id = ${inboundTaskId}::uuid
WHERE a.account_id = ${accountId}::uuid AND a.conversation_id = ${conversationId}::uuid
AND a.state IN ('sending', 'uncertain')
AND ((a.started_at <= incoming.created_at
AND a.expires_at >= incoming.created_at)
OR (a.started_at < ${sendTime} + interval '1 second'
AND a.expires_at >= ${sendTime}))`;
const known = attempts.find((a) => a.actor !== 'unknown');
if (known) return known;
if (attempts.some((a) => a.pending)) return { actor: 'unknown', holding: false, pending: true };
if (attempts.length) return { actor: 'unknown', holding: false, pending: false };
return { actor: 'human', holding: false, pending: false };
}
/** 已有回声时沿用它的真实时刻,并把可证明的 actor/holding 补正。 */
export async function reconcileSelfMessage(
tx: SqlClient, accountId: string, appInfo: string, actor: string, holding: boolean, fallback: Date, fallbackId?: string,
): Promise<{ sendTime: Date; existing: boolean; messageId: string }> {
const echo = await tx.$queryRaw<{ send_time: Date; message_id: string }[]>`
SELECT send_time, message_id FROM app.outbound_echo_receipts
WHERE account_id = ${accountId}::uuid AND app_info = ${appInfo}`;
const rows = await tx.$queryRaw<{ send_time: Date; id: string }[]>`
UPDATE app.messages SET outbound_actor_type = ${actor},
raw = coalesce(raw, '{}'::jsonb) || ${JSON.stringify({ sentBy: 'ai-service', holding, sourceUnknown: false })}::jsonb
WHERE account_id = ${accountId}::uuid AND app_info = ${appInfo} AND direction = 'outbound'
RETURNING send_time, id`;
// 未落库时沿用请求前分配的 id;回声只是时间证据,⛔ 不能拿晚到回声的 id 越过新患者。
// 已有消息及附件引用保持稳定,自记投影另外用请求边界保护。
const messageId = rows[0]?.id ?? fallbackId ?? echo[0]?.message_id ??
(await tx.$queryRaw<{ id: string }[]>`SELECT app.uuidv7() AS id`)[0]!.id;
return { sendTime: rows[0]?.send_time ?? echo[0]?.send_time ?? fallback, existing: rows.length > 0, messageId };
}
/** NULL 历史行仍保留旧语义;显式 unknown ⛔ 不当作已经有人工回复。 */
export function isHumanActor(actor: string | null | undefined): boolean {
return actor !== 'agent' && actor !== 'unknown';
}
......@@ -22,6 +22,7 @@ import { RoundInterruptService } from '../agent/llm/round-interrupt.service';
import { MODEL_MEDIA_KINDS } from '../agent/llm/patient-media.service';
import { RealtimeService } from '../../common/realtime/realtime.service';
import { MediaTransferService } from '../media/media-transfer.service';
import { echoOrigin, hasNewerInbound, lockConversation, rememberEcho } from '../outbound/send-origin';
import {
TASK_TYPE,
type ClaimedTask,
......@@ -156,6 +157,19 @@ export class InboundEcomHandler implements OutboxHandler {
);
const direction = msg.outgoing ? 'outbound' : 'inbound';
const echoMessageId = msg.outgoing
? await rememberEcho(this.prisma, task.tenantId, p.accountId, conv.id, msg.appInfo, sendTime, task.id)
: null;
const origin = msg.outgoing
? await echoOrigin(this.prisma, p.accountId, conv.id, msg.appInfo, sendTime, task.id)
: null;
if (origin?.pending) {
return { ok: false, kind: 'hold', reason: '等待同会话发送响应以确认回声来源', retryAfterMs: 1000 };
}
const responseMessageId = origin?.projectionId ?? echoMessageId ?? task.id;
const responseOrderTime = origin?.projectionTime ?? sendTime;
const humanResponse = direction === 'outbound' && origin?.actor === 'human';
let currentResponse = false;
/**
* ⭐⭐ **群里说话的是同事的个人微信**(群资料里标过「是同事」,2026-09-23 评审会)。
*
......@@ -178,21 +192,31 @@ export class InboundEcomHandler implements OutboxHandler {
/** ⭐ 这条消息有没有真排出一轮 agent —— 决定敢不敢去掐在跑的那一轮 */
let agentRoundQueued = 0;
await this.prisma.$transaction(async (tx) => {
await lockConversation(tx, conv.id);
if (msg.outgoing) {
// 响应缺 sendTime 时自记时间可能与回声不同;appInfo 才是同一发送的稳定标识。
const existing = await tx.$queryRaw<{ id: string }[]>`
SELECT id FROM app.messages
WHERE account_id = ${p.accountId}::uuid AND app_info = ${msg.appInfo} AND direction = 'outbound'
LIMIT 1`;
if (existing.length) return;
}
// append-only:靠 (account_id, app_info, send_time) 唯一键 DO NOTHING
const inserted = await tx.$executeRaw`
INSERT INTO app.messages (
tenant_id, account_id, conversation_id, app_info, server_id, seq, send_time,
id, tenant_id, account_id, conversation_id, app_info, server_id, seq, send_time,
event_type, content_type, kind, sender_vid, sender_display_name,
direction, outbound_source, outbound_actor_type, text_content, refer_app_info, raw)
VALUES (
${responseMessageId}::uuid,
${task.tenantId}::uuid, ${p.accountId}::uuid, ${conv.id}::uuid, ${msg.appInfo},
${msg.serverId ? BigInt(msg.serverId) : null}, ${msg.seq ? BigInt(msg.seq) : null},
${sendTime}, ${msg.eventType}, ${msg.contentType}, ${msg.kind},
${msg.sender}, ${msg.senderName || null}, ${direction},
${msg.outgoing ? 'echo' : null},
${msg.outgoing ? 'human' : null},
${origin?.actor ?? null},
${msg.text || null}, ${msg.referAppInfo || null},
${JSON.stringify(msg.raw)}::jsonb)
${JSON.stringify({ ...(msg.raw as Record<string, unknown>), ...(origin?.holding ? { holding: true } : {}), ...(origin?.actor === 'unknown' ? { sourceUnknown: true } : {}) })}::jsonb)
ON CONFLICT (account_id, app_info, send_time) DO NOTHING`;
if (inserted === 0) {
......@@ -230,11 +254,16 @@ export class InboundEcomHandler implements OutboxHandler {
* ⛔ 不知道是号主本人还是同事拿他手机发的。⛔ 别硬塞一个 user id 去凑 ——
* 那一列是**举证**用的,编一个比空着坏。(列本来就可空)
*/
if (direction === 'outbound' || byColleague) {
await this.closeHandoffs(tx, task.tenantId, conv.id);
if (humanResponse || byColleague) {
// 手机人工与群同事共用消息顺序;旧回复不能结束后来患者的新等待。
currentResponse = !(await hasNewerInbound(tx, conv.id, responseOrderTime, responseMessageId));
if (currentResponse) await this.closeHandoffs(tx, task.tenantId, conv.id);
}
// ⭐ 同事在群里说的话,投影按「我方回复」推进(清等待位和未处理的 @)—— 见 byColleague
await this.updateProjections(tx, conv.id, p, msg, sendTime, byColleague ? 'outbound' : direction);
// holding/来源未知都不能算“人已经接住”;保持等待位和交接。
if (!origin?.holding && origin?.actor !== 'unknown') {
await this.updateProjections(tx, conv.id, p, msg, sendTime, byColleague ? 'outbound' : direction, origin?.actor, responseMessageId, responseOrderTime);
}
await this.saveMedia(tx, task.tenantId, p, msg, sendTime);
/**
......@@ -433,11 +462,8 @@ export class InboundEcomHandler implements OutboxHandler {
* 人刚说了话 ⇒ 患者**已经有回音了** ⇒ 掐掉 AI 那轮⛔ 不会留下沉默。
* ⇒ 这一支**不需要**那个前提。
*
* ⭐⭐ **`landed && outbound` 就已经等于「人从自己客户端发的」** ——
* ⛔ 不用再判 actor。理由:AI 发的那条**我们自己已经落过库了**
* (`OutboundEcomHandler` 发完就用响应里的 appInfo 落一条 `self_recorded`)
* ⇒ 它的回声撞 `uq_msg` ⇒ `inserted === 0` ⇒ `landed` 为 false。
* ⇒ 能走到这儿的 outbound,一定是**我们没记过**的那种 = 人在自己手机上发的。
* 回声可能先于 HTTP 响应。仅在持久发送来源明确为 human 时掐;
* ⛔ 未落过 self_recorded 不能证明是手机人工,来源未知时保留原轮和交接。
* ⚠️ 判据必须是「有没有**人**发过」,⛔ 不是「我方发过」——
* AI 自己发的⛔ 不算,否则一段里多条会互相掐([17 §6])。
* ⚠️ 工作台发的那条走的是**另一条路**(`conversation.controller` 入队时掐)——
......@@ -445,7 +471,7 @@ export class InboundEcomHandler implements OutboxHandler {
* ⚠️⚠️ 它⛔ 管不到**已经入队**的那一条 —— 那归投递时的时宜判断
* (`staleness.ts` 的 `human_spoke`),两者接力。
*/
if (landed && (direction === 'outbound' || byColleague)) {
if (landed && currentResponse) {
for (const k of await this.roundKeysOf(task.tenantId, conv.id, msg.convKey)) {
this.interrupts.interrupt(k, `人回了一句 ${msg.appInfo}`);
}
......@@ -706,6 +732,9 @@ export class InboundEcomHandler implements OutboxHandler {
msg: NormalizedMsg,
sendTime: Date,
direction: 'inbound' | 'outbound',
actor: 'agent' | 'human' | 'unknown' = 'human',
messageId?: string | null,
responseOrderTime: Date = sendTime,
): Promise<void> {
// ⭐ 预览文案与 [11062] 会话同步**共用** ecom-client 那一份(见 previewOf 注释)。
// ⚠️ 可能是 null(文本类却没正文) ⇒ 这一列写 NULL,⛔ 别兜底成 `[text]`。
......@@ -736,7 +765,7 @@ export class InboundEcomHandler implements OutboxHandler {
THEN coalesce(waiting_since, ${sendTime})
ELSE NULL END,
last_reply_actor_type = CASE WHEN ${direction} = 'outbound'
THEN 'human' ELSE last_reply_actor_type END,
THEN ${actor} ELSE last_reply_actor_type END,
-- ⚠️ 任何 outbound 消息就清空未处理的 @([13 §8]);
-- 入站且 @ 到本号 → 置成这个号的归属人
pending_mention_user_id = CASE
......@@ -745,7 +774,12 @@ export class InboundEcomHandler implements OutboxHandler {
SELECT user_id FROM app.wecom_accounts WHERE id = ${p.accountId}::uuid)
ELSE pending_mention_user_id END
WHERE id = ${conversationId}::uuid
AND last_msg_at <= ${sendTime}`;
AND last_msg_at <= ${sendTime}
AND (${messageId}::uuid IS NULL OR NOT EXISTS (
SELECT 1 FROM app.messages newer
WHERE newer.conversation_id = ${conversationId}::uuid AND newer.direction = 'inbound'
AND (newer.send_time, newer.id) > (${responseOrderTime}, ${messageId}::uuid)
))`;
}
/**
......
......@@ -223,6 +223,13 @@ export class OutboxWorker implements OnModuleInit, OnModuleDestroy {
-- ⭐ 只挑这个池的 —— 两个池各跑各的循环,⛔ 互不排队
AND (CASE WHEN o.task_type = ${MODEL_TASK_TYPE} THEN 'model' WHEN o.task_type = 'host.appointment.write' THEN 'host' ELSE 'ecom' END) = ${pool}
AND NOT EXISTS (SELECT 1 FROM busy b WHERE b.route_key = o.route_key)
-- 生命周期必须保持原始事件顺序;退避只延后队首,不能让新上线越过旧掉线。
AND (o.route_key IS NULL OR o.route_key NOT LIKE 'acctlife:%' OR NOT EXISTS (
SELECT 1 FROM app.outbox earlier
WHERE earlier.route_key = o.route_key
AND earlier.status IN ('pending', 'processing')
AND (earlier.created_at, earlier.id) < (o.created_at, o.id)
))
), picked AS (
-- ⚠️ 排序⛔ 别删:不带它时积压超过一批,最老的那条不保证先走
SELECT id FROM cand WHERE rn = 1 ORDER BY scheduled_at, id LIMIT ${limit}
......
......@@ -1521,7 +1521,7 @@ export class ConversationService {
: m.direction === 'outbound'
? m.outboundActorType === 'agent'
? 'AI 助手'
: '我'
: m.outboundActorType === 'unknown' ? '来源待确认' : '我'
: // 见上面 senderName 那段:快照 → 群昵称 → 通讯录 → 后 4 位
m.senderDisplayName ||
senderName.get(m.senderVid) ||
......@@ -2578,13 +2578,13 @@ export function aiAlreadyReplied(
*
* ⭐ 那块「AI 建议回复」问的是「这句还没说出去,要不要你来发」—— 人已经回了,就没有这句了
* ⇒ 界面把整块收起(2026-09-30 用户截图:同事回完「没有李德华医生」,下面还摆着「AI 没有给这条写建议,通常是…」和一颗按不动的按钮)。
* ⚠️ 判据是 `outbound_actor_type <> 'agent'`,空也算人(这一列是后加的,同 reception 那条),⛔ 不是 `= 'human'`。
* 空历史值仍算人;显式 unknown 不算人,⛔ 不能把未确定来源当作人工已接住。
* ⚠️ 跳过的三样同 `aiAlreadyReplied`:系统消息、撤回的、转人工后先回的那一句(那句是 AI 垫的,⛔ 不算人回了)。
* @param msgs ⚠️ **send_time DESC**,同 `aiAlreadyReplied`。
*/
export function humanAlreadyReplied(msgs: Parameters<typeof aiAlreadyReplied>[0]): boolean {
const last = msgs.find((m) => m.kind !== 'system' && !m.revokedAt && !isHoldMessage(m.raw));
return !!last && last.direction === 'outbound' && last.outboundActorType !== 'agent';
return !!last && last.direction === 'outbound' && last.outboundActorType !== 'agent' && last.outboundActorType !== 'unknown';
}
/** 转人工后先回患者的那一句(`recordSelfSent` 在报文里记的 `holding`) */
......
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
const migration = readFileSync(
join(__dirname, '../prisma/migrations/20261011140000_channel_delivery_reliability/migration.sql'),
'utf8',
);
// 执行迁移中的真实 UPDATE;仅适配 schema、UUID cast 和 JSON 取文本的数据库语法。
const sql = migration
.match(/UPDATE app\.outbox o[\s\S]*?;/)![0]
.replace(/app\./g, '')
.replace('UPDATE outbox o', 'UPDATE outbox AS o')
.replace(/::text/g, '')
.replace("o.payload->>'accountId'", "json_extract(o.payload, '$.accountId')")
.replace("o.payload#>>'{body,type}'", "CAST(json_extract(o.payload, '$.body.type') AS TEXT)");
describe('升级前生命周期队列回填', () => {
it('只补同租户真实账号的待处理生命周期,保留既有路由与历史记录', () => {
const db = new DatabaseSync(':memory:');
try {
db.exec(`CREATE TABLE wecom_accounts(id TEXT PRIMARY KEY, tenant_id TEXT);
CREATE TABLE outbox(id TEXT PRIMARY KEY, tenant_id TEXT, task_type TEXT,
status TEXT, route_key TEXT, payload TEXT, created_at INTEGER, scheduled_at INTEGER, lease_until INTEGER);
INSERT INTO wecom_accounts VALUES ('account-a', 'tenant-a');`);
const rows = [
[
'logout',
'tenant-a',
'inbound.ecom',
'pending',
null,
{ accountId: 'account-a', body: { type: 11026 } },
],
[
'login',
'tenant-a',
'inbound.ecom',
'processing',
null,
{ accountId: 'account-a', body: { type: '11027' } },
],
[
'risk',
'tenant-a',
'inbound.ecom',
'pending',
null,
{ accountId: 'account-a', body: { type: 100008 } },
],
[
'other-tenant',
'tenant-b',
'inbound.ecom',
'pending',
null,
{ accountId: 'account-a', body: { type: 11026 } },
],
[
'missing-account',
'tenant-a',
'inbound.ecom',
'pending',
null,
{ accountId: 'missing', body: { type: 11026 } },
],
['malformed', 'tenant-a', 'inbound.ecom', 'pending', null, { body: { type: 11026 } }],
[
'message',
'tenant-a',
'inbound.ecom',
'pending',
null,
{ accountId: 'account-a', body: { type: 11041 } },
],
[
'finished',
'tenant-a',
'inbound.ecom',
'succeeded',
null,
{ accountId: 'account-a', body: { type: 11026 } },
],
[
'existing-route',
'tenant-a',
'inbound.ecom',
'pending',
'kept',
{ accountId: 'account-a', body: { type: 11026 } },
],
[
'other-task',
'tenant-a',
'outbound.ecom.text',
'pending',
null,
{ accountId: 'account-a', body: { type: 11026 } },
],
] as const;
const insert = db.prepare('INSERT INTO outbox VALUES (?, ?, ?, ?, ?, ?, 10, 20, 30)');
for (const [id, tenant, type, status, route, payload] of rows)
insert.run(id, tenant, type, status, route, JSON.stringify(payload));
db.exec(sql);
const landed = db
.prepare(
'SELECT id, route_key, created_at, scheduled_at, lease_until FROM outbox ORDER BY id',
)
.all();
for (const row of landed) {
expect(row.route_key).toBe(
['logout', 'login', 'risk'].includes(String(row.id))
? 'acctlife:account-a'
: row.id === 'existing-route'
? 'kept'
: null,
);
expect([row.created_at, row.scheduled_at, row.lease_until]).toEqual([10, 20, 30]);
}
// 幂等执行不改已有路由,也不会把无关任务混入生命周期队首。
db.exec(sql);
expect(
db
.prepare(
'SELECT id, route_key, created_at, scheduled_at, lease_until FROM outbox ORDER BY id',
)
.all(),
).toEqual(landed);
} finally {
db.close();
}
});
});
......@@ -1053,7 +1053,6 @@ describe('最近就诊诊所的口径', () => {
*/
test.each([
["type IN ('encounter_record', 'emr_record')"],
['superseded_at IS NULL'],
['ORDER BY patient_id, occurred_at DESC'],
["clinic_id IS NOT NULL AND clinic_id <> ''"],
])('⭐⭐ 两边都有 %s', (needle) => {
......@@ -1061,6 +1060,37 @@ describe('最近就诊诊所的口径', () => {
expect(pac('src/modules/plan/engine/plan-engine.service.ts')).toContain(needle);
});
// 旧 fulfilled 终态保留审计,仅查 superseded_at=NULL 会把日期更正前的记录当末诊。
// 三条真实读取都先排除有较新版本的行,再判当前头 active/fulfilled。
test.each([
['开放面', 'src/modules/open/open-profile.service.ts', 'SELECT DISTINCT ON (patient_id) patient_id, clinic_id'],
['plan 批量', 'src/modules/plan/engine/plan-engine.service.ts', 'SELECT DISTINCT ON (patient_id) patient_id, clinic_id'],
['plan 单刷', 'src/modules/plan/engine/plan-engine.service.ts', 'SELECT clinic_id'],
])('%s 末诊读取按当前版本头和有效状态判定', (_name, path, select) => {
const src = pac(path);
const start = src.indexOf(select);
expect(start).toBeGreaterThan(0);
const query = src.slice(start, src.indexOf('`', start));
expect(query).toContain("FROM patient_facts vis");
expect(query).toContain("${currentFactHeadSql('vis')}");
expect(query).toContain("vis.status IN ('active', 'fulfilled')");
expect(query).toMatch(/host_id = \$\{scope\.hostId\}/);
expect(query).toMatch(/tenant_id = \$\{scope\.tenantId\}/);
expect(query).not.toContain('superseded_at IS NULL');
});
test('版本头子查询按完整身份排除较新版本,新头取消时旧 fulfilled 不能复活', () => {
const src = pac('src/modules/facts/current-fact.ts');
const start = src.indexOf('return Prisma.sql`');
const sql = src.slice(start, src.indexOf('`;', start));
for (const key of ['host_id', 'tenant_id', 'source_unit', 'subject_id']) {
expect(sql).toContain('${newer}.' + key + ' = ${row}.' + key);
}
expect(sql).toContain('${newer}.version > ${row}.version');
expect(sql).not.toContain('status');
});
/** ⚠️ 必须按宿主 + 租户隔离 —— patient_facts 是按宿主分的 */
test('⚠️ 开放面那条带 host_id + tenant_id', () => {
const src = pac('src/modules/open/open-profile.service.ts');
......
......@@ -26,8 +26,9 @@ describe('⭐⭐ 入站:同事在群里说话 = 人回了', () => {
});
it('⭐⭐ 结掉交接、投影按「我方回复」推进', () => {
expect(h).toMatch(/if \(direction === 'outbound' \|\| byColleague\) \{\s+await this\.closeHandoffs\(tx, task\.tenantId, conv\.id\);/);
expect(h).toMatch(/this\.updateProjections\(tx, conv\.id, p, msg, sendTime, byColleague \? 'outbound' : direction\)/);
expect(h).toMatch(/if \(humanResponse \|\| byColleague\) \{[\s\S]*?hasNewerInbound\(tx, conv\.id, responseOrderTime, responseMessageId\)/);
expect(h).toMatch(/if \(currentResponse\) await this\.closeHandoffs\(tx, task\.tenantId, conv\.id\);/);
expect(h).toMatch(/this\.updateProjections\(tx, conv\.id, p, msg, sendTime, byColleague \? 'outbound' : direction, origin\?\.actor, responseMessageId, responseOrderTime\)/);
});
/**
......@@ -36,7 +37,7 @@ describe('⭐⭐ 入站:同事在群里说话 = 人回了', () => {
*/
it('⭐ 唤醒⛔ 不另判;AI 正在跑的那一轮掐掉(整个群的每一行)', () => {
expect(h).not.toMatch(/AND NOT \$\{byColleague\}/);
expect(h).toMatch(/if \(landed && \(direction === 'outbound' \|\| byColleague\)\) \{\s+for \(const k of await this\.roundKeysOf\(/);
expect(h).toMatch(/if \(landed && currentResponse\) \{\s+for \(const k of await this\.roundKeysOf\(/);
});
});
......
......@@ -70,9 +70,16 @@ it('未绑定联系人的头像可见,其他账号头像不可见', async () =
it('限定诊所入口可使用通讯录动作,但绑定患者仍校验患者权限', async () => {
const { access } = fixture();
const guard = new IntegrationAccessGuard(access);
const run = (path: string, method: string, body = {}) => guard.canActivate({ switchToHttp: () => ({
getRequest: () => ({ path: '/api' + path, method, body, user: embedded }),
}) } as never);
const run = (path: string, method: string, body = {}) => {
const binding = /^\/contacts\/([^/]+)\/patient$/.exec(path);
return guard.canActivate({ switchToHttp: () => ({
getRequest: () => ({
path: '/api' + path, method, body, user: embedded,
route: { path: binding ? '/api/contacts/:id/patient' : '/api' + path },
params: binding ? { id: decodeURIComponent(binding[1]!) } : {},
}),
}) } as never);
};
await expect(run('/contacts/requests', 'GET')).resolves.toBe(true);
await expect(run('/contacts/wecom-search', 'GET')).resolves.toBe(true);
const assertContact = jest.spyOn(access, 'assertContact').mockResolvedValue();
......
......@@ -31,7 +31,8 @@ describe('⭐⭐⭐ 交接的结束判据:两条路都要覆盖', () => {
const src = read(INBOUND);
expect(src).toMatch(/private async closeHandoffs\(/);
// ⭐ 群里标过「是同事」的个人微信说话也算(2026-09-23,见 colleague-wechat.spec)
expect(src).toMatch(/if \(direction === 'outbound' \|\| byColleague\) \{\s*await this\.closeHandoffs\(/);
expect(src).toMatch(/if \(humanResponse \|\| byColleague\) \{[\s\S]*?currentResponse = !\(await hasNewerInbound\(tx, conv\.id, responseOrderTime, responseMessageId\)\);\s+if \(currentResponse\) await this\.closeHandoffs\(/);
expect(src).toMatch(/const humanResponse = direction === 'outbound' && origin\?\.actor === 'human'/);
});
/**
......
......@@ -86,7 +86,7 @@ it('同一渠道跨诊所仍过滤单聊,混合患者群拒绝',async()=>{
it('未批准工作台模块时,深链也不绕过准入',async()=>{
const u=user();u.hostScope!.capabilities=['conversation.read'];u.hostScope!.modules=['appointment-confirm'];
const guard=new IntegrationAccessGuard({capability:()=> 'conversation.read'} as never);
await expect(guard.canActivate({switchToHttp:()=>({getRequest:()=>({path:'/api/conversations/'+leaf,method:'GET',user:u})})} as never)).rejects.toMatchObject({code:12811});
await expect(guard.canActivate({switchToHttp:()=>({getRequest:()=>({path:'/api/conversations/'+leaf,route:{path:'/api/conversations/:id'},params:{id:leaf},method:'GET',user:u})})} as never)).rejects.toMatchObject({code:12811});
});
it('PAC 不可用时不能降级为全部患者',async()=>{
const service=new IntegrationAccessService({} as never,{getPatients:async()=>{throw Error('offline');}} as never,{} as never);
......
......@@ -35,7 +35,9 @@ it.each([['image', 11030], ['file', 11031]] as const)('%s 投递等待异步签
const { assets } = fixture();
const call = jest.fn(async () => { throw new Error('stop-before-real-send'); });
const handler = new OutboundMediaHandler(
{ wecomAccount: { findUnique: async () => ({ deviceId: 'device' }) } } as never,
{ wecomAccount: { findUnique: async () => ({ deviceId: 'device' }) },
$queryRaw: async (parts: TemplateStringsArray) => parts.join('').includes('INSERT INTO app.outbound_send_attempts') ? [{ id: 'attempt', started_at: new Date() }] : [],
$executeRaw: async () => 1 } as never,
{ call } as never, { check: async () => ({ pass: true }) } as never,
{} as never, assets, {} as never,
);
......
......@@ -2,6 +2,7 @@ import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { stripTsComments } from './strip-comments';
import { PERMISSIONS, permissionsOf, hasPermission, isReadOnly, ADMIN_PERMISSIONS } from '../src/common/permissions';
import { CAPABILITIES, roleCapabilities } from '../src/modules/integration/integration.schema';
/**
* 权限点 —— **代码里真正用来判的东西**。
......@@ -191,7 +192,6 @@ describe('P2 组织与人员后台的权限点', () => {
});
it('平台权限不混入客户角色和宿主能力契约', () => {
const { CAPABILITIES,roleCapabilities }=require('../src/modules/integration/integration.schema');
expect(CAPABILITIES.some((p:string)=>p.startsWith('platform.'))).toBe(false);
for (const role of ['owner','staff','viewer']) expect(permissionsOf(role).some(p=>p.startsWith('platform.'))).toBe(false);
expect(roleCapabilities('platform_admin')).toEqual([]);
......
......@@ -31,6 +31,7 @@ export function ChatTimeline({ items, group }: { items: readonly TimelineItem[];
{items.map((m, i) => {
const mine = m.side === 'me';
const isAi = mine && m.actor === 'agent';
const isUnknown = mine && m.actor === 'unknown';
// ⚠️ 和**上一条**比,不是和上一条同侧的比 —— 时间轴是一条,不分左右
const stamp = needsStamp(m.at, i > 0 ? items[i - 1]!.at : null) ? stampOf(m.at) : null;
......@@ -74,12 +75,13 @@ export function ChatTimeline({ items, group }: { items: readonly TimelineItem[];
<div
className={`flex max-w-[80%] min-w-0 flex-col ${mine ? 'items-end' : 'items-start'}`}
>
{showWho || isAi ? (
{showWho || isAi || isUnknown ? (
<div className="mb-1 flex items-baseline gap-1.5 px-0.5">
{showWho ? (
<span className="text-[10.5px] font-semibold text-slate-500">{m.who}</span>
) : null}
{isAi ? <Badge tone="ai">AI</Badge> : null}
{isUnknown ? <span className="text-[10.5px] text-slate-500">来源待确认</span> : null}
</div>
) : null}
<div
......
......@@ -586,8 +586,17 @@ export class HostService implements OnModuleInit, OnModuleDestroy {
});
}
private async deliverResults(): Promise<void> {
// 轮询目的地的审计事件继续保留,但不能占住其他目的地的投递批次。
const destinations = ['pac', 'ai'].filter(
(destination) => process.env[`HOST_RESULT_${destination.toUpperCase()}_URL`],
);
if (!destinations.length) return;
const events = await this.db.resultOutbox.findMany({
where: { deliveredAt: null, nextAttemptAt: { lte: new Date() } },
where: {
deliveredAt: null,
nextAttemptAt: { lte: new Date() },
destination: { in: destinations },
},
take: 5,
orderBy: { createdAt: 'asc' },
});
......
import { HostService } from '../src/service';
describe('结果回调与轮询目的地共存', () => {
const saved = { ...process.env };
afterEach(() => {
process.env = { ...saved };
jest.restoreAllMocks();
});
function fixture() {
const events = Array.from({ length: 5 }, (_, index) => ({
id: `pac-${index}`,
destination: 'pac',
createdAt: index,
deliveredAt: null as Date | null,
nextAttemptAt: new Date(0),
attempts: 0,
payload: {},
}));
events.push({
id: 'ai-result',
destination: 'ai',
createdAt: 10,
deliveredAt: null,
nextAttemptAt: new Date(0),
attempts: 0,
payload: {},
});
const db = {
resultOutbox: {
findMany: jest.fn(async ({ where, take }: any) =>
events
.filter(
(event) =>
!event.deliveredAt &&
event.nextAttemptAt <= where.nextAttemptAt.lte &&
(!where.destination || where.destination.in.includes(event.destination)),
)
.sort((a, b) => a.createdAt - b.createdAt)
.slice(0, take),
),
update: jest.fn(async ({ where, data }: any) => {
const event = events.find((value) => value.id === where.id)!;
Object.assign(event, data);
return event;
}),
},
};
return { events, db, service: new HostService(db as any) as any };
}
it('五条轮询结果不能挡住已配置的另一目的地回调,之后启用仍能投递旧审计事件', async () => {
delete process.env.HOST_RESULT_PAC_URL;
process.env.HOST_RESULT_AI_URL = 'https://example.invalid/ai';
process.env.HOST_AI_KEY = 'local-test-key';
const fetchMock = jest.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true } as Response);
const { service, events } = fixture();
await service.deliverResults();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]![0]).toBe(process.env.HOST_RESULT_AI_URL);
expect(events[5]!.deliveredAt).toBeInstanceOf(Date);
expect(events.slice(0, 5).every((value) => value.deliveredAt === null)).toBe(true);
process.env.HOST_RESULT_PAC_URL = 'https://example.invalid/pac';
process.env.HOST_PAC_KEY = 'local-test-key';
await service.deliverResults();
expect(fetchMock).toHaveBeenCalledTimes(6);
expect(events.every((value) => value.deliveredAt instanceof Date)).toBe(true);
});
it('没有回调配置时保留结果,且不空转查询数据库', async () => {
delete process.env.HOST_RESULT_PAC_URL;
delete process.env.HOST_RESULT_AI_URL;
const fetchMock = jest.spyOn(globalThis, 'fetch');
const { service, db } = fixture();
await service.deliverResults();
expect(db.resultOutbox.findMany).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
});
......@@ -12,6 +12,7 @@ import {
emrNarrativeText,
} from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service';
import { currentFactsByIdentity } from '../../facts/current-fact';
import { AiCallRunnerService } from '../ai-call-runner.service';
import type { StreamEvent } from '../ai-call-runner.service';
import type { AiCall, AiCallContext } from '../ai-call.interface';
......@@ -500,18 +501,18 @@ export class PlanScriptOrchestrator {
orderBy: { version: 'desc' },
});
const facts = await this.prisma.patientFact.findMany({
const factHistory = await this.prisma.patientFact.findMany({
where: {
hostId: plan.hostId,
tenantId: plan.tenantId,
patientId: patient.id,
// ⚠️ 必须包含 fulfilled — actual treatment_record 状态是 fulfilled 不是 active,
// 漏 fulfilled 会让 AI 看不到实际治疗记录(医生 / 治疗类目 / occurredAt)→
// primaryDoctor 偏移、pendingTreatments 漏算等。跟 plan-aggregate 同口径。
status: { in: ['active', 'fulfilled'] },
},
orderBy: { occurredAt: 'desc' },
});
// 先选完整身份的版本头,再判有效状态;⛔ 不能把订正前的 fulfilled 同时喂给 AI。
const facts = (await currentFactsByIdentity(this.prisma, factHistory))
.filter((fact) => fact.status === 'active' || fact.status === 'fulfilled')
.sort((a, b) => (b.occurredAt?.getTime() ?? Infinity) - (a.occurredAt?.getTime() ?? Infinity));
// 历史联系(诊所回访/联系记录)— 最近 5 条(深度档用;页面"历史联系"同源)
const returnVisits = await this.prisma.patientReturnVisit.findMany({
......
......@@ -4,6 +4,7 @@ import type { Prisma } from '@prisma/client';
import { calcAge, maskName } from '@pac/utils';
import { planScenarioLabel, personaFeatureMeta } from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service';
import { currentFactsByIdentity } from '../../facts/current-fact';
import { ChainComposerService } from '../../plan/engine/chain-composer.service';
import { AiCallRunnerService } from '../ai-call-runner.service';
import { DraftPlanSummaryCall } from '../calls/draft-plan-summary/call';
......@@ -256,15 +257,17 @@ export class PlanSummaryOrchestrator {
orderBy: { version: 'desc' },
});
const facts = await this.prisma.patientFact.findMany({
const factHistory = await this.prisma.patientFact.findMany({
where: {
hostId: plan.hostId,
tenantId: plan.tenantId,
patientId: patient.id,
status: 'active',
},
orderBy: { occurredAt: 'desc' },
});
const facts = (await currentFactsByIdentity(this.prisma, factHistory))
.filter((fact) => fact.status === 'active' || fact.status === 'fulfilled')
.sort((a, b) => (b.occurredAt?.getTime() ?? Infinity) - (a.occurredAt?.getTime() ?? Infinity));
return { plan, patient, persona, facts };
}
......
......@@ -14,6 +14,7 @@ import {
treatmentCategoryNameZh,
} from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service';
import { currentFactsByIdentity } from '../../facts/current-fact';
import { AiCallRunnerService } from '../ai-call-runner.service';
import { readBenefitText } from '../../plan/plan-assignment.service';
import { DraftRecallBriefCall } from '../calls/draft-recall-brief/call';
......@@ -167,17 +168,22 @@ export class RecallBriefOrchestrator {
};
});
// 3) 历史治疗:treatment_record actual 按 category 计数
const txFacts = await this.prisma.patientFact.findMany({
// 3) 当前有效事实:单患者一次读取,先选版本头,再分治疗历史和医生建议。
const factHistory = await this.prisma.patientFact.findMany({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId: plan.patientId,
type: 'treatment_record',
kind: 'actual',
},
select: { content: true, occurredAt: true },
orderBy: { occurredAt: 'desc' },
select: {
id: true, hostId: true, tenantId: true, sourceUnit: true, subjectId: true, version: true,
type: true, kind: true, status: true, content: true, occurredAt: true },
});
const facts = (await currentFactsByIdentity(this.prisma, factHistory))
.filter((fact) => fact.status === 'active' || fact.status === 'fulfilled')
.sort((a, b) => (b.occurredAt?.getTime() ?? Infinity) - (a.occurredAt?.getTime() ?? Infinity));
const txFacts = facts.filter((fact) => fact.type === 'treatment_record' && fact.kind === 'actual');
const catCount = new Map<string, number>();
// 复查切入锚点(第④问事实依据):最近一次实际治疗 + 最近一次洁牙/检查(preventive/periodontic)
let lastVisitAt: Date | null = null;
......@@ -209,42 +215,10 @@ export class RecallBriefOrchestrator {
// 治疗计划 = treatment_record kind='planned' / 医生建议 = recommendation_record /
// 医嘱 = emr_record.content.doctor_advice(真实医嘱文本;"常嘱"级短水词丢弃)
const clip = (s: string, max: number): string => (s.length > max ? `${s.slice(0, max)}…` : s);
const [plannedFacts, recFacts, emrFacts] = await Promise.all([
this.prisma.patientFact.findMany({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId: plan.patientId,
type: 'treatment_record',
kind: 'planned',
},
orderBy: { occurredAt: 'desc' },
take: 3,
select: { content: true, occurredAt: true },
}),
this.prisma.patientFact.findMany({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId: plan.patientId,
type: 'recommendation_record',
},
orderBy: { occurredAt: 'desc' },
take: 3,
select: { content: true, occurredAt: true },
}),
this.prisma.patientFact.findMany({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId: plan.patientId,
type: 'emr_record',
},
orderBy: { occurredAt: 'desc' },
take: 5,
select: { content: true, occurredAt: true },
}),
]);
// ⛔ 不能先 LIMIT 历史行:旧版本会占掉名额,或者让已取消的旧建议复活。
const plannedFacts = facts.filter((fact) => fact.type === 'treatment_record' && fact.kind === 'planned').slice(0, 3);
const recFacts = facts.filter((fact) => fact.type === 'recommendation_record').slice(0, 3);
const emrFacts = facts.filter((fact) => fact.type === 'emr_record').slice(0, 5);
const doctorGuidance: DraftRecallBriefInput['doctorGuidance'] = [
...plannedFacts.map((f) => {
const c = (f.content ?? {}) as Record<string, unknown>;
......
......@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { lookupDxTreatment, resolverCategoriesFor } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import { currentFactHeadSql } from '../facts/current-fact';
import {
buildGapCore,
GAP_FLAGS_BY_PRIMARY,
......@@ -71,6 +72,7 @@ export class PotentialTreatmentSelector {
AND p.id = ${patientId}::uuid
AND p.active = true
AND sig.status = 'active'
AND ${currentFactHeadSql('sig')}
AND sig.type IN ('diagnosis_record', 'recommendation_record')
AND sig.content->>'code' = ANY(${allCodes}::text[])
AND COALESCE(sig.occurred_at, sig.planned_for) IS NOT NULL
......
import { Prisma } from '@prisma/client';
/**
* 当前事实按版本头判断,不能先按业务状态筛选。
* fulfilled/cancelled 等终态历史刻意保留原状;旧终态仍不代表当前有效版本。
*/
export function currentFactHeadSql(alias: string): Prisma.Sql {
if (!/^[a-z][a-z0-9_]*$/i.test(alias)) throw new Error('Invalid fact table alias');
const row = Prisma.raw(alias);
const newer = Prisma.raw(`${alias}_newer`);
// 五列唯一索引已覆盖等值身份 + version 范围查找;不排序/聚合全租户事实表。
return Prisma.sql`NOT EXISTS (
SELECT 1 FROM patient_facts ${newer}
WHERE ${newer}.host_id = ${row}.host_id
AND ${newer}.tenant_id = ${row}.tenant_id
AND ${newer}.source_unit = ${row}.source_unit
AND ${newer}.subject_id = ${row}.subject_id
AND ${newer}.version > ${row}.version
)`;
}
interface VersionedFact {
hostId: string;
tenantId: string;
sourceUnit: string;
subjectId: string;
version: number;
}
/** 仅取局部候选头,不能单独证明当前头;当前事实读取须用 currentFactsByIdentity 全局确认。 */
export function latestFactsByIdentity<T extends VersionedFact>(facts: readonly T[]): T[] {
const latest = new Map<string, T>();
for (const fact of facts) {
const key = JSON.stringify([fact.hostId, fact.tenantId, fact.sourceUnit, fact.subjectId]);
const previous = latest.get(key);
if (!previous || previous.version < fact.version) latest.set(key, fact);
}
return [...latest.values()];
}
/**
* 患者历史里的候选头必须再按全局完整身份核验:宿主可纠正同一源记录的患者引用。
* 仅按候选 UUID 查版本存在性,不读取另一患者的正文;PK + 五列身份索引限定查找范围。
* 不在这里筛状态/时间/类型,调用方核验头后再应用业务窗口。
*/
export async function currentFactsByIdentity<T extends VersionedFact & { id: string }>(
prisma: Pick<Prisma.TransactionClient, '$queryRaw'>,
facts: readonly T[],
): Promise<T[]> {
const candidates = latestFactsByIdentity(facts);
if (candidates.length === 0) return [];
const current = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT candidate.id FROM patient_facts candidate
WHERE candidate.id = ANY(${candidates.map((fact) => fact.id)}::uuid[])
AND ${currentFactHeadSql('candidate')}
`;
const ids = new Set(current.map((fact) => fact.id));
return candidates.filter((fact) => ids.has(fact.id));
}
import { Body, Controller, Post, ForbiddenException } from '@nestjs/common';
import { Body, Controller, Post, ForbiddenException, HttpException } from '@nestjs/common';
import { randomUUID, createHmac, timingSafeEqual } from 'node:crypto';
import { z } from 'zod';
import { Permission } from '@pac/types';
import { ApiCode, Permission } from '@pac/types';
import { BizError } from '../../common/errors/biz-error';
import { RequirePermission } from '../../common/decorators/permissions.decorator';
import { HostOperationClient, type HostPrincipal, type OperationView } from '@pac/host-connector';
import {
......@@ -172,39 +173,67 @@ export class HostWriteController {
@CurrentUser() user: AuthenticatedUser,
@Body() raw: unknown,
) {
const body = z
.strictObject({
ticket: z.string().max(5000),
doctorResourceId: z.string().min(1),
startsAt: z.iso.datetime({ offset: true }),
durationMinutes: z.number().int().min(5).max(240),
notes: z.string().max(500).default(''),
})
.parse(raw);
const client = this.client();
const [encoded, signature] = body.ticket.split('.');
const expected = this.sign(encoded ?? ''),
a = Buffer.from(signature ?? ''),
b = Buffer.from(expected);
if (!encoded || a.length !== b.length || !timingSafeEqual(a, b))
throw new ForbiddenException('预约确认信息不正确');
const ticket = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
if (ticket.userId !== user.sub || ticket.expires < Date.now())
throw new ForbiddenException('预约确认信息已过期');
const patient = await this.patient(scope, ticket.input.patient.patientId);
return client.request<OperationView>('/operations', {
requestId: ticket.requestId,
principal: await this.principal(scope, user),
...(ticket.planId ? { sourceRef: ticket.planId } : {}),
input: {
...ticket.input,
patient,
doctorResourceId: body.doctorResourceId,
startsAt: body.startsAt,
durationMinutes: body.durationMinutes,
notes: body.notes,
},
});
let client: HostOperationClient;
let request: unknown;
try {
const body = z
.strictObject({
ticket: z.string().max(5000),
doctorResourceId: z.string().min(1),
startsAt: z.iso.datetime({ offset: true }),
durationMinutes: z.number().int().min(5).max(240),
notes: z.string().max(500).default(''),
})
.parse(raw);
client = this.client();
const [encoded, signature] = body.ticket.split('.');
const expected = this.sign(encoded ?? ''),
a = Buffer.from(signature ?? ''),
b = Buffer.from(expected);
if (!encoded || a.length !== b.length || !timingSafeEqual(a, b))
throw new ForbiddenException('预约确认信息不正确');
const ticket = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
if (ticket.userId !== user.sub || ticket.expires < Date.now())
throw new ForbiddenException('预约确认信息已过期');
const patient = await this.patient(scope, ticket.input.patient.patientId);
request = {
requestId: ticket.requestId,
principal: await this.principal(scope, user),
...(ticket.planId ? { sourceRef: ticket.planId } : {}),
input: {
...ticket.input,
patient,
doctorResourceId: body.doctorResourceId,
startsAt: body.startsAt,
durationMinutes: body.durationMinutes,
notes: body.notes,
},
};
} catch (error) {
// 只有本地准备阶段能证明请求尚未交给宿主服务。投递后的超时/拒绝不能冒充未提交。
const details = { appointmentSubmission: 'not_sent' };
if (error instanceof BizError)
throw new BizError(error.code, error.msg, { ...details, rejectionDetails: error.details });
if (error instanceof z.ZodError)
throw new BizError(ApiCode.CLIENT_VALIDATION_FAILED, '预约信息不正确,请检查后重新提交', {
...details,
issues: error.issues,
});
if (error instanceof HttpException) {
const response = error.getResponse();
throw new HttpException(
{
...(typeof response === 'object' && response !== null ? response : {}),
message: error.message,
details,
},
error.getStatus(),
{ cause: error },
);
}
throw error;
}
return client.request<OperationView>('/operations', request);
}
@Post('status')
async status(
......
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { currentFactHeadSql } from '../facts/current-fact';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { OpenFactGate, type OpenPurpose } from './open-fact-gate.service';
import { redactJson, redactText } from './open-redact';
......@@ -68,13 +69,14 @@ export class OpenAppointmentService {
}[]
>`
SELECT patient_id, subject_id, kind, status, clinic_id, planned_for, title, summary, content
FROM patient_facts
FROM patient_facts f
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND type = 'appointment_record'
AND planned_for IS NOT NULL
AND planned_for >= ${new Date(input.from)}
AND planned_for < ${new Date(input.to)}
AND ${currentFactHeadSql('f')}
AND status IN ('active', 'fulfilled')
AND (${clinics}::text[] IS NULL OR clinic_id = ANY(${clinics}::text[]))
AND (${cur === null}::boolean
......
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { currentFactHeadSql } from '../facts/current-fact';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { OpenFactGate, type OpenPurpose } from './open-fact-gate.service';
import { toOpenFact, type OpenFact, type OpenFactRow } from './open-fact';
......@@ -58,12 +59,13 @@ export class OpenFactWindowService {
? await this.prisma.$queryRaw<Row[]>`
SELECT id, patient_id, subject_id, type, kind, status, clinic_id,
occurred_at, planned_for, valid_until, title, summary, content, occurred_at AS at
FROM patient_facts
FROM patient_facts f
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND type = ANY(${input.types}::text[])
AND occurred_at >= ${from}
AND occurred_at < ${to}
AND ${currentFactHeadSql('f')}
AND status IN ('active', 'fulfilled')
AND (${clinics}::text[] IS NULL OR clinic_id = ANY(${clinics}::text[]))
AND (${cur === null}::boolean
......@@ -73,12 +75,13 @@ export class OpenFactWindowService {
: await this.prisma.$queryRaw<Row[]>`
SELECT id, patient_id, subject_id, type, kind, status, clinic_id,
occurred_at, planned_for, valid_until, title, summary, content, planned_for AS at
FROM patient_facts
FROM patient_facts f
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND type = ANY(${input.types}::text[])
AND planned_for >= ${from}
AND planned_for < ${to}
AND ${currentFactHeadSql('f')}
AND status IN ('active', 'fulfilled')
AND (${clinics}::text[] IS NULL OR clinic_id = ANY(${clinics}::text[]))
AND (${cur === null}::boolean
......
......@@ -4,6 +4,7 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de
import { PatientService } from '../patient/patient.service';
import { redactJson, redactText } from './open-redact';
import { toOpenFact, type OpenFact } from './open-fact';
import { currentFactHeadSql } from '../facts/current-fact';
export type { OpenFact };
......@@ -24,7 +25,7 @@ export interface OpenPatientProfile {
*
* ⚠️⚠️ 口径和 plan 引擎的「跟进归属诊所」**逐字一致**(`plan-engine.service.ts`
* 的 `prefetchForBatch`):该患者**最新一条** `encounter_record` / `emr_record`
* (未作废、`occurred_at` 与 `clinic_id` 非空)的 `clinic_id`。
* (完整身份当前版本且 active/fulfilled、`occurred_at` 与 `clinic_id` 非空)的 `clinic_id`。
* ⛔ 别另起口径(比如按预约、按收费)—— 同一个患者在召回池里归 A 店、
* 在 AI 接待里归 B 店,那是两个系统各说各话。
* ⚠️ 和 `lastVisitAt` **不是同一个来源**:那一列来自 profile 派生(DATE),
......@@ -191,14 +192,15 @@ export class OpenProfileService {
*/
this.prisma.$queryRaw<Array<{ patient_id: string; clinic_id: string }>>`
SELECT DISTINCT ON (patient_id) patient_id, clinic_id
FROM patient_facts
FROM patient_facts vis
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND patient_id = ANY(${ids}::uuid[])
AND type IN ('encounter_record', 'emr_record')
AND occurred_at IS NOT NULL
AND clinic_id IS NOT NULL AND clinic_id <> ''
AND superseded_at IS NULL
AND ${currentFactHeadSql('vis')}
AND vis.status IN ('active', 'fulfilled')
ORDER BY patient_id, occurred_at DESC
`,
]);
......@@ -345,13 +347,14 @@ export class OpenProfileService {
PARTITION BY patient_id
ORDER BY COALESCE(occurred_at, planned_for) DESC NULLS LAST, id DESC) AS rn,
COUNT(*) OVER (PARTITION BY patient_id) AS total
FROM patient_facts
FROM patient_facts f
WHERE patient_id = ANY(${ids}::uuid[])
AND host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
-- ⚠️ 只要"当前成立"的:superseded 是被新版本替换的旧版,
-- cancelled/expired/invalidated 是已经不作数的。全给出去时间轴会自相矛盾
-- (同一次预约既"已到诊"又"已取消")。
AND ${currentFactHeadSql('f')}
AND status IN ('active', 'fulfilled')
AND (${typeFilter}::text[] IS NULL OR type = ANY(${typeFilter}::text[]))
) t
......
......@@ -8,6 +8,7 @@ import { FeatureRegistry } from './features/feature.registry';
import { visitFactsOf, visitSpanOf } from './features/visit-facts';
import { PotentialTreatmentSelector } from '../clinical-gap/potential-treatment.selector';
import { personaWriteVerdict, type StoredPersonaFeature } from './persona-diff';
import { currentFactHeadSql, currentFactsByIdentity } from '../facts/current-fact';
import type {
ActiveFact,
FeatureExtractorContext,
......@@ -154,8 +155,9 @@ export class PersonaService {
CASE type WHEN 'payment_record' THEN (content->>'amount_cents')::bigint
WHEN 'recharge_record' THEN (content->>'amount_cents')::bigint
WHEN 'refund_record' THEN -(content->>'amount_cents')::bigint END) AS net
FROM patient_facts
FROM patient_facts mf
WHERE host_id = ${hostId}::uuid AND tenant_id = ${tenantId}
AND ${currentFactHeadSql('mf')}
AND status IN ('active','fulfilled')
AND type IN ('payment_record','recharge_record','refund_record')
GROUP BY patient_id)
......@@ -329,21 +331,20 @@ export class PersonaService {
};
try {
// ⭐ 一次取,内存分两份 —— 原来对 patient_facts 查两遍(同一患者、同一索引、两次往返):
// ① status IN (active, fulfilled) → factsByType
// ② type=appointment_record AND status != superseded → appointmentsAll
// ② 是 ① 的超集在 appointment 这一类上的放宽(多了 cancelled / no_show 等状态),
// 所以取 `status != 'superseded'` 一把捞回来,再在内存里切成两份,语义逐字节等价。
// (全量回填时每患者省 1 次往返;12.7 → 11.7)
const allFacts = await this.prisma.patientFact.findMany({
// 只读当前患者,沿 patient_id 索引取版本流;先选完整身份的头,再按业务状态分流。
// ⛔ 不能先筛 active/fulfilled:新头 cancelled/invalidated 时会把旧 fulfilled 错当当前事实。
const factHistory = await this.prisma.patientFact.findMany({
where: {
hostId: patient.hostId,
tenantId: patient.tenantId,
patientId: patient.id,
status: { not: 'superseded' },
},
select: {
id: true,
hostId: true,
tenantId: true,
sourceUnit: true,
version: true,
subjectId: true,
type: true,
kind: true,
......@@ -354,6 +355,7 @@ export class PersonaService {
content: true,
},
});
const allFacts = await currentFactsByIdentity(this.prisma, factHistory);
// factsByType 口径不变:只收 active / fulfilled。
// 跟 chain-composer / scenario SQL 对齐:actual treatment 完成后 status='fulfilled',
......@@ -367,7 +369,7 @@ export class PersonaService {
arr.push(f as ActiveFact);
factsByType.set(f.type, arr);
}
if (f.type === 'appointment_record') appointmentsAll.push(f as ActiveFact);
if (f.type === 'appointment_record' && f.status !== 'superseded') appointmentsAll.push(f as ActiveFact);
}
// 关系边(家庭构成等用)— relatedPatientId 是否解析不影响,只看 relationship 类型
......
......@@ -10,6 +10,7 @@ import {
} from '@pac/types';
import { BizError } from '../../common/errors/biz-error';
import { PrismaService } from '../../prisma/prisma.service';
import { currentFactsByIdentity } from '../facts/current-fact';
import { ChainComposerService } from '../plan/engine/chain-composer.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { toothSet } from '../sync/pipeline/parsers/tooth-position.util';
......@@ -380,19 +381,26 @@ export class PlanAggregateService {
});
}
private loadActiveFacts(scope: TenantScopeContext, patientId: string) {
return this.prisma.patientFact.findMany({
private async loadActiveFacts(scope: TenantScopeContext, patientId: string) {
const history = await this.prisma.patientFact.findMany({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId,
// ⚠️ 不能只 active — treatment_record(actual) 完成后 status='fulfilled',
// appointment_record 到诊后也 fulfilled。这些都属"现行已发生事实"应展示。
// 排除的是被 superseded / cancelled / expired / invalidated 的历史/失效版本。
status: { in: ['active', 'fulfilled'] },
// 先取完整身份版本头再判状态,否则新头取消会把旧 fulfilled 复活。
},
orderBy: { occurredAt: 'desc' },
});
// 取头后的时刻可能已订正;按当前时刻重排,保持原 DESC 的 NULLS FIRST 契约。
return (await currentFactsByIdentity(this.prisma, history))
.filter((fact) => ['active', 'fulfilled'].includes(fact.status))
.sort((a, b) => {
if (!a.occurredAt) return b.occurredAt ? -1 : 0;
if (!b.occurredAt) return 1;
return b.occurredAt.getTime() - a.occurredAt.getTime();
});
}
}
......
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { currentFactHeadSql } from '../../facts/current-fact';
import { PrismaService } from '../../../prisma/prisma.service';
import { TreatmentInitiationRecallScenario } from './scenarios/treatment-initiation-recall.scenario';
import type { PlanScenarioPlugin, ScenarioHit, ScenarioScope } from './scenario.interface';
......@@ -1132,14 +1133,15 @@ export class PlanEngineService {
// DISTINCT ON 取 occurred_at 最新那条;跟"就诊冷静期"用同一 type 口径,语义一致。
this.prisma.$queryRaw<Array<{ patient_id: string; clinic_id: string }>>`
SELECT DISTINCT ON (patient_id) patient_id, clinic_id
FROM patient_facts
FROM patient_facts vis
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND patient_id = ANY(${ids}::uuid[])
AND type IN ('encounter_record', 'emr_record')
AND occurred_at IS NOT NULL
AND clinic_id IS NOT NULL AND clinic_id <> ''
AND superseded_at IS NULL
AND ${currentFactHeadSql('vis')}
AND vis.status IN ('active', 'fulfilled')
ORDER BY patient_id, occurred_at DESC
`,
]);
......@@ -1182,14 +1184,15 @@ export class PlanEngineService {
): Promise<string | null> {
const rows = await this.prisma.$queryRaw<Array<{ clinic_id: string }>>`
SELECT clinic_id
FROM patient_facts
FROM patient_facts vis
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND patient_id = ${patientId}::uuid
AND type IN ('encounter_record', 'emr_record')
AND occurred_at IS NOT NULL
AND clinic_id IS NOT NULL AND clinic_id <> ''
AND superseded_at IS NULL
AND ${currentFactHeadSql('vis')}
AND vis.status IN ('active', 'fulfilled')
ORDER BY occurred_at DESC
LIMIT 1
`;
......
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { currentFactHeadSql } from '../../../facts/current-fact';
import {
PlanScenario,
lookupDxTreatment,
......@@ -452,7 +453,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
-- ⚠️ 必须 IS NOT TRUE 而非 = false:该列可空,NULL = 宿主未提供该信号
-- (jvs-dw 未接入),= false 会因 NULL != false 把这些患者全挡在池外。
AND pp.host_follow_up_active IS NOT TRUE -- ② 宿主跟进闸
AND sig.status = 'active' -- ③ 触发信号 active 版本
AND sig.status = 'active' AND ${currentFactHeadSql('sig')} -- ③ 触发信号 active 版本
AND sig.type IN ('diagnosis_record', 'recommendation_record') -- ③ 信号类型
AND sig.content->>'code' = ANY(${allCodes}::text[]) -- ③ 信号 code 命中
AND COALESCE(sig.occurred_at, sig.planned_for) IS NOT NULL -- ④ 时间不为空
......@@ -468,7 +469,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
-- 不按 complaint_category 匹配(陆伟根:K07 诊断 + 修复预约 → 反正会来诊)
-- 只看 status='active' + future
SELECT 1 FROM patient_facts appt
WHERE appt.patient_id = p.id
WHERE appt.patient_id = p.id AND ${currentFactHeadSql('appt')}
AND appt.type = 'appointment_record'
AND appt.status = 'active'
AND COALESCE(appt.planned_for, appt.occurred_at) > ${scope.now}::timestamptz
......@@ -484,7 +485,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
-- 锚"最近到诊"而非"诊断日" → 补 cooldown 漏的"旧诊断 + 近期又来过"场景。
-- 不含 appointment(那是预约不一定到诊;未来预约已由 ⑤b 单独排除)。
SELECT 1 FROM patient_facts vis
WHERE vis.patient_id = p.id
WHERE vis.patient_id = p.id AND ${currentFactHeadSql('vis')}
AND vis.status IN ('active', 'fulfilled')
AND vis.type IN ('encounter_record', 'emr_record')
AND vis.occurred_at IS NOT NULL
AND vis.occurred_at > ${this.daysAgo(scope.now, POST_VISIT_COOLDOWN_DAYS)}::timestamptz
......@@ -827,7 +829,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
const rows = await this.prisma.$queryRaw<Array<{ patient_id: string; cat: string }>>`
SELECT pf.patient_id, jsonb_array_elements_text(pf.content->'intent_categories') AS cat
FROM patient_facts pf
WHERE pf.patient_id = ANY(${patientIds}::uuid[])
WHERE pf.patient_id = ANY(${patientIds}::uuid[]) AND ${currentFactHeadSql('pf')}
AND pf.type = 'consultation_record' AND pf.status = 'active'
`;
const ctx = new Map<string, Set<string>>();
......
......@@ -5,6 +5,7 @@ import {
diagnosisCodeNameZh,
} from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service';
import { currentFactsByIdentity } from '../../facts/current-fact';
import { GAP_PRIMARY_GROUPS } from '../../clinical-gap/potential-treatment-gap.sql';
import { TreatmentInitiationRecallScenario } from '../engine/scenarios/treatment-initiation-recall.scenario';
......@@ -72,15 +73,19 @@ export class RecallDebugService {
}
// ── 当下事实(active 信号 + actual 治疗 active/fulfilled + active 预约)──
const facts = await this.prisma.patientFact.findMany({
const factHistory = await this.prisma.patientFact.findMany({
where: {
patientId,
hostId,
tenantId,
status: { in: ['active', 'fulfilled'] },
},
select: {
id: true,
hostId: true,
tenantId: true,
sourceUnit: true,
subjectId: true,
version: true,
type: true,
kind: true,
status: true,
......@@ -90,6 +95,9 @@ export class RecallDebugService {
},
});
const facts = (await currentFactsByIdentity(this.prisma, factHistory))
.filter((f) => f.status === 'active' || f.status === 'fulfilled');
// ── 为什么没召:合规闸 + 逐信号漏斗 ──
const compliance = {
active: patient.active,
......
......@@ -10,7 +10,11 @@ import { BizError } from '../../../common/errors/biz-error';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { TransactionSynthesizer } from '../pipeline/transaction-synthesizer';
import { ParserPipeline, type BatchItem } from '../pipeline/parser-pipeline.service';
import {
ParserPipeline,
type BatchItem,
type PipelineRunMetrics,
} from '../pipeline/parser-pipeline.service';
import {
AssemblerEngine,
type AssemblerResult,
......@@ -437,6 +441,8 @@ export class ColdImportService {
const seenTenants = new Set<string>();
const perResource: PerResourceStats[] = [];
// 请求内收集,不放单例状态:归属移出者未出现在本批新 transaction 中也必须触发重算。
const affectedPatients = new Map<string, string>();
let firstError: string | null = null;
try {
// upsert 资源(patient 主档走 partial 语义:canonical 未提供的字段不覆盖存量、
......@@ -474,6 +480,7 @@ export class ColdImportService {
normalize,
syncLog.id,
!!opts.dryRun,
affectedPatients,
);
perResource.push(stats);
}
......@@ -482,7 +489,7 @@ export class ColdImportService {
this.logger.error(`ingestRawTables(${opts.source}) 异常: ${firstError}`);
}
// touched 患者 = 本 syncLog 写入的 transaction 的 distinct (patientId, tenantId)
// touched = 本批事实涉及的患者(含移出患者) + 本 syncLog transaction 的患者。
// 注:upsert 资源(patient/relation/return_visit)不产 transaction → 纯主档推送 touched 为空,
// 不触发 persona 重算(主档字段变更由后续事实事件驱动的重算自然吸收)。
const touchedRows = await this.prisma.patientTransaction.findMany({
......@@ -490,9 +497,8 @@ export class ColdImportService {
select: { patientId: true, tenantId: true },
distinct: ['patientId'],
});
const touched = touchedRows
.filter((t): t is { patientId: string; tenantId: string } => !!t.patientId)
.map((t) => ({ patientId: t.patientId, tenantId: t.tenantId }));
for (const t of touchedRows) if (t.patientId) affectedPatients.set(t.patientId, t.tenantId);
const touched = [...affectedPatients].map(([patientId, tenantId]) => ({ patientId, tenantId }));
const agg = perResource.reduce(
(a, s) => ({
......@@ -1905,6 +1911,7 @@ export class ColdImportService {
normalize: { amountUnit: 'fen' | 'yuan'; timezone: string },
syncLogId: string | undefined,
dryRun: boolean,
affectedPatients?: Map<string, string>,
): Promise<PerResourceStats> {
const stats: PerResourceStats = this.zeroPerResource(config.canonical);
......@@ -1943,6 +1950,7 @@ export class ColdImportService {
buffer: buf,
stats,
config,
affectedPatients,
});
buf.length = 0;
};
......@@ -2079,8 +2087,9 @@ export class ColdImportService {
}>;
stats: PerResourceStats;
config: AssemblerConfig;
affectedPatients?: Map<string, string>;
}): Promise<void> {
const { tenantId, buffer, stats, config } = args;
const { tenantId, buffer, stats, config, affectedPatients } = args;
if (buffer.length === 0) return;
const hostId = buffer[0]!.tx.hostId as string;
......@@ -2102,7 +2111,7 @@ export class ColdImportService {
this.logger.warn(
`createMany tx 批失败,降级 per-row: ${err instanceof Error ? err.message : String(err)}`,
);
await this.fallbackPerRowWrite(buffer, stats, config);
await this.fallbackPerRowWrite(buffer, stats, config, affectedPatients);
return;
}
stats.transactionsWritten += createdCount;
......@@ -2169,6 +2178,12 @@ export class ColdImportService {
if (items.length === 0) return;
const metrics = await this.parserPipeline.runForBatch(items);
if (affectedPatients) {
for (const item of items) {
if (item.transaction.patientId) affectedPatients.set(item.transaction.patientId, tenantId);
}
this.collectPreviousPatients(metrics, tenantId, affectedPatients);
}
stats.factsCreated += metrics.factsCreated;
stats.factsSuperseded += metrics.factsSuperseded;
stats.factsUnchanged += metrics.factsUnchanged;
......@@ -2187,6 +2202,7 @@ export class ColdImportService {
}>,
stats: PerResourceStats,
config: AssemblerConfig,
affectedPatients?: Map<string, string>,
): Promise<void> {
for (const b of buffer) {
const txn = b.tx;
......@@ -2207,6 +2223,10 @@ export class ColdImportService {
},
canonicalRow: b.canonicalRow,
});
if (affectedPatients) {
if (created.patientId) affectedPatients.set(created.patientId, created.tenantId);
this.collectPreviousPatients(metrics, created.tenantId, affectedPatients);
}
stats.factsCreated += metrics.factsCreated;
stats.factsSuperseded += metrics.factsSuperseded;
stats.factsUnchanged += metrics.factsUnchanged;
......@@ -2238,6 +2258,10 @@ export class ColdImportService {
},
canonicalRow: b.canonicalRow,
});
if (affectedPatients) {
if (existing.patientId) affectedPatients.set(existing.patientId, existing.tenantId);
this.collectPreviousPatients(metrics, existing.tenantId, affectedPatients);
}
stats.factsCreated += metrics.factsCreated;
stats.factsSuperseded += metrics.factsSuperseded;
stats.factsUnchanged += metrics.factsUnchanged;
......@@ -2256,6 +2280,16 @@ export class ColdImportService {
}
}
private collectPreviousPatients(
metrics: PipelineRunMetrics,
tenantId: string,
affectedPatients: Map<string, string>,
): void {
for (const write of metrics.writes) {
if (write.previousPatientId) affectedPatients.set(write.previousPatientId, tenantId);
}
}
private async buildPatientIndex(
hostId: string,
tenantId: string,
......
......@@ -173,7 +173,7 @@ export class PipelineDispatcher {
try {
const created = await this.prisma.patientTransaction.create({ data: txn });
m.transactionsWritten++;
await this.runParser(created, canonicalRow, m);
await this.runParser(created, canonicalRow, m, touchedPatients);
if (created.patientId) touchedPatients.add(created.patientId);
} catch (err) {
const code = (err as { code?: string }).code;
......@@ -188,7 +188,7 @@ export class PipelineDispatcher {
},
});
if (existing) {
await this.runParser(existing, canonicalRow, m);
await this.runParser(existing, canonicalRow, m, touchedPatients);
if (existing.patientId) touchedPatients.add(existing.patientId);
}
} else {
......@@ -235,6 +235,7 @@ export class PipelineDispatcher {
txn: { id: string; hostId: string; tenantId: string; patientId: string | null; action: string; subjectType: string; subjectId: string; occurredAt: Date; clinicId: string },
canonicalRow: Record<string, unknown>,
m: DispatchMetrics,
touchedPatients: Set<string>,
): Promise<void> {
// patientId=null 仅剩一种情形:事件本身没带患者引用(patientExternalId 缺失)——
// 带引用的未知患者已由 stub auto-create 兜住(见 dispatchTables)。无患者可挂 → 跳过 parser。
......@@ -258,6 +259,10 @@ export class PipelineDispatcher {
},
canonicalRow,
});
// 同一源记录纠正患者引用后,移出患者的画像/召回也需要刷新。
for (const write of r.writes) {
if (write.previousPatientId) touchedPatients.add(write.previousPatientId);
}
m.factsCreated += r.factsCreated;
m.factsSuperseded += r.factsSuperseded;
m.factsUnchanged += r.factsUnchanged;
......
......@@ -11,8 +11,8 @@ import { PersonaService } from '../../modules/persona/persona.service';
*
* 流程:
* 1. 调 PersonaService.recompute() — 内部 eventWatermark 幂等(noop 短路)
* 2. 成功(status=success/partial)→ 链式 enqueue plan-recompute
* 3. noop / failed → 不向下游传播
* 2. 成功处理 → 链式 enqueue plan-recompute(标签不变也可能新建/取消了预约)
* 3. failed → 不向下游传播
*/
@Processor(QueueName.PERSONA_RECOMPUTE, { concurrency: 4 })
export class PersonaRecomputeProcessor extends WorkerHost {
......@@ -43,14 +43,11 @@ export class PersonaRecomputeProcessor extends WorkerHost {
`persona=${r.personaId ?? 'noop'} v${r.version} features=${r.featureCount} duration=${r.durationMs}ms`,
);
// 链式触发:只在画像**语义**确有更新时(success / partial)才下推 plan 重算。
// noop 水位幂等,压根没算 → plan 也不会变
// unchanged 算了但内容一字未变 → 同上
// refreshed 只有天数/年龄这类易变值变了 → 召回打分读的是 rfm.segment / urgency.level /
// lifecycle.stage 这些**分档**,分档没变就不必重排(变了会判成 semantic)
// failed persona 写库挂了 → 不传播,等重试 / 死信
// 召回还读未来预约、近期到诊、宿主跟进等运营闸,这些变化未必改变画像标签。
// noop 也可能来自仅更新主档/profile 的 push,或时间经过召回门槛;不能据此判计划无变化。
// 下游按主体等待/执行去重,PlanEngine 自身负责无变化时幂等。
let planEnqueued = false;
if (r.status === 'success' || r.status === 'partial') {
if (['success', 'partial', 'noop', 'unchanged', 'refreshed'].includes(r.status)) {
try {
await this.queue.enqueuePlanRecompute({
hostId,
......
......@@ -2,11 +2,11 @@
* BullMQ 队列名常量 — 单一真理源。
*
* 命名规则:`<domain>-<verb>`,小写连字符。
* - persona-recompute: 重算单 patient persona 版本(jobName = patientId 串行)
* - plan-recompute: 重算单 patient followup plans(jobName = patientId 串行)
* - plan-asset-generate: 生成 PlanScript + PlanSummary(jobName = planId 串行)
* - persona-recompute: 重算单 patient persona 版本(按 patientId 去重)
* - plan-recompute: 重算单 patient followup plans(按 patientId 去重)
* - plan-asset-generate: 生成 PlanScript + PlanSummary(按 planId 去重)
*
* 三个队列各自 jobName 锁 per-queue,跨队列允许并发(数据依赖天然)。
* 三个队列各自 deduplication id 隔离等待/执行,跨队列允许并发(数据依赖天然)。
* 详见 apps/pac-docs/content/docs/architecture/three-layer-model.mdx "事件驱动主路径"。
*/
export const QueueName = {
......
......@@ -15,11 +15,9 @@ import type {
* QueueProducer — 给业务模块(sync / persona / plan)统一的入队接口。
*
* 设计点:
* - `jobName` 是 BullMQ 的去重 / 串行键 ⭐
* · persona / plan recompute → jobName = patientId(同 patient 串行)
* · plan-asset-generate → jobName = planId(同 plan 串行)
* - 同 jobName 在队列里只保留 1 个 waiting 的 job(用 deduplication)
* 防止 1 秒内来 10 个 txn 队列里堆 10 个重复 job
* - jobId 每次唯一,已完成/失败的审计记录不会吞掉后续更新。
* - deduplication.id 按 patient/plan 稳定:等待时合并,执行期间保留最后一次更新,
* 完成/失败后释放键并补跑尾任务(keepLastIfActive),同主体不会并行执行。
* - 失败重试 / 退避 由 QueueDefaults 统一管理,模块不自定义
*/
@Injectable()
......@@ -40,7 +38,7 @@ export class QueueProducer {
/**
* 去抖模式(push 摄入路径用)—— 磨平存量/增量差异的关键:
* 存量按批重放时同一患者被几十个批次连续 touch,立即入队会重算几十遍(每遍读全量历史,
* Node-CPU-bound)。去抖 = delay 一个时间窗 + 窗内按 (patientId, 时间桶) 去重:
* Node-CPU-bound)。去抖 = delay 一个时间窗 + 等待期间按 patientId 去重:
* 窗内多批合并成一次重算(job 到期跑时读的是当时 DB 全量,晚落的批自动包含);
* 增量只多付一个窗的延迟(召回是日级节奏,无感)。
* 非 push 路径(单患者刷新等要即时反馈)不传,行为不变。
......@@ -50,11 +48,11 @@ export class QueueProducer {
): Promise<void> {
if (opts?.debounce) {
const windowMs = resolvePersonaDebounceMs();
// 时间桶 jobId:同患者同桶内的重复 add 被 BullMQ jobId 去重吃掉,只留第一个(delayed);
// 跨桶产生新 job —— 最坏每患者每窗 1 次重算,50 批连续推 → 2~3 次而非 50 次。
const bucket = Math.floor(Date.now() / windowMs);
// 固定主体去重键,不要用被保留的终态 jobId 去重。
// 延迟期间合并批次;若执行时又有输入,完成后另跑最后一条,避免丢掉计算快照之后的更新。
await this.personaQ.add(job.patientId, job, {
jobId: `persona_${job.patientId}_${bucket}`,
jobId: randomUUID(),
deduplication: { id: `persona_${job.patientId}`, keepLastIfActive: true },
delay: windowMs,
...QueueDefaults[QueueName.PERSONA_RECOMPUTE],
});
......@@ -63,9 +61,9 @@ export class QueueProducer {
);
return;
}
// jobId 用 _ 分隔(BullMQ 不允许 :);同 patient 的 waiting job 自动去重
await this.personaQ.add(job.patientId, job, {
jobId: `persona_${job.patientId}`,
jobId: randomUUID(),
deduplication: { id: `persona_${job.patientId}`, keepLastIfActive: true },
...QueueDefaults[QueueName.PERSONA_RECOMPUTE],
});
this.logger.debug(
......@@ -75,7 +73,8 @@ export class QueueProducer {
async enqueuePlanRecompute(job: PlanRecomputeJob): Promise<void> {
await this.planQ.add(job.patientId, job, {
jobId: `plan_${job.patientId}`,
jobId: randomUUID(),
deduplication: { id: `plan_${job.patientId}`, keepLastIfActive: true },
...QueueDefaults[QueueName.PLAN_RECOMPUTE],
});
this.logger.debug(
......@@ -85,7 +84,8 @@ export class QueueProducer {
async enqueuePlanAssetGenerate(job: PlanAssetGenerateJob): Promise<void> {
await this.assetQ.add(job.planId, job, {
jobId: `asset_${job.planId}`,
jobId: randomUUID(),
deduplication: { id: `asset_${job.planId}`, keepLastIfActive: true },
...QueueDefaults[QueueName.PLAN_ASSET_GENERATE],
});
this.logger.debug(
......
import { Prisma } from '@prisma/client';
import { currentFactQueryResult } from './helpers/current-fact-query';
import { PlanScriptOrchestrator } from '../src/modules/ai/orchestrators/plan-script.orchestrator';
import { PlanSummaryOrchestrator } from '../src/modules/ai/orchestrators/plan-summary.orchestrator';
import { RecallBriefOrchestrator } from '../src/modules/ai/orchestrators/recall-brief.orchestrator';
import type { DraftRecallBriefInput } from '../src/modules/ai/calls/draft-recall-brief/input.types';
import type { TenantScopeContext } from '../src/common/decorators/tenant-scope.decorator';
const scope = { hostId: 'host', tenantId: 'tenant' } as TenantScopeContext;
const base = {
hostId: 'host',
tenantId: 'tenant',
sourceUnit: 'clinic',
patientId: 'patient',
subjectId: 'treatment:1',
type: 'treatment_record',
kind: 'actual',
status: 'fulfilled',
version: 1,
id: 'old',
occurredAt: new Date('2026-10-02T10:00:00Z'),
content: { category: 'implant' },
};
const corrected = {
...base,
id: 'corrected',
version: 2,
occurredAt: new Date('2026-09-30T10:00:00Z'),
};
const recent = {
...base,
id: 'recent',
subjectId: 'treatment:2',
occurredAt: new Date('2026-10-01T10:00:00Z'),
};
const cancelledOld = {
...base,
id: 'cancel-old',
subjectId: 'treatment:cancel',
kind: 'planned',
status: 'active',
};
const cancelledHead = { ...cancelledOld, id: 'cancel-head', version: 2, status: 'cancelled' };
const history = [base, cancelledOld, cancelledHead, recent, corrected];
function fixture(rows = history, globalRows = rows) {
return {
followupPlan: {
findUnique: jest
.fn()
.mockResolvedValue({
id: 'plan',
patientId: 'patient',
...scope,
reasons: [
{
scenario: 'treatment_initiation_recall',
subKey: 'missing_tooth',
priorityScore: 1,
reason: '缺牙',
signals: { triggers: [{ type: 'diagnosis', code: 'K08' }] },
},
],
assignment: null,
}),
},
patient: {
findUnique: jest.fn().mockResolvedValue({ id: 'patient', name: null, birthDate: null }),
},
patientFact: { findMany: jest.fn().mockResolvedValue(rows) },
persona: { findFirst: jest.fn().mockResolvedValue(null) },
patientReturnVisit: { findMany: jest.fn().mockResolvedValue([]) },
planSummary: {
findUnique: jest.fn().mockResolvedValue(null),
upsert: jest.fn().mockResolvedValue({}),
},
$queryRaw: jest.fn(async (strings: TemplateStringsArray, ...values: unknown[]) =>
currentFactQueryResult(globalRows, strings, values) ?? [{ total: 0n, since: null, hangups: 0n }]),
};
}
describe('AI 事实输入只消费当前版本', () => {
test.each(['script', 'summary'] as const)(
'%s 日期更正不重复治疗,取消头不会复活旧计划,按当前日期排序',
async (kind) => {
const prisma = fixture();
const orchestrator =
kind === 'script'
? new PlanScriptOrchestrator(
prisma as never,
{} as never,
{} as never,
{} as never,
{} as never,
)
: new PlanSummaryOrchestrator(prisma as never, {} as never, {} as never, {} as never);
const context = await (
orchestrator as unknown as {
loadPlanContext(id: string): Promise<{ facts: typeof history }>;
}
).loadPlanContext('plan');
expect(context.facts.map((fact) => fact.id)).toEqual(['recent', 'corrected']);
const where = prisma.patientFact.findMany.mock.calls[0]![0].where;
expect(where).toMatchObject({ hostId: 'host', tenantId: 'tenant', patientId: 'patient' });
expect(where.status).toBeUndefined();
expect(base.status).toBe('fulfilled');
},
);
test.each(['script', 'summary', 'brief'] as const)(
'%s 排除已更正归属到另一患者的 fulfilled,不读取新患者正文', async (kind) => {
const prisma = fixture([base], [base, { ...corrected, patientId: 'another-patient', content: { category: 'secret' } }]);
if (kind === 'brief') {
const runner = { run: jest.fn().mockResolvedValue({ output: { who: '', history: '', problem: '', hook: '' }, source: 'agent', invocationId: 'i' }) };
await new RecallBriefOrchestrator(prisma as never, runner as never, {} as never).getOrGenerate(scope, 'plan');
expect((runner.run.mock.calls[0]![1] as DraftRecallBriefInput).treatmentHistory).toEqual([]);
} else {
const service = kind === 'script'
? new PlanScriptOrchestrator(prisma as never, {} as never, {} as never, {} as never, {} as never)
: new PlanSummaryOrchestrator(prisma as never, {} as never, {} as never, {} as never);
const result = await (service as unknown as { loadPlanContext(id: string): Promise<{facts: unknown[]}> }).loadPlanContext('plan');
expect(result.facts).toEqual([]);
}
const query = prisma.$queryRaw.mock.calls.find(([strings]) => strings.join('').includes('SELECT candidate.id'))!;
const sql = Prisma.sql(query[0], ...query.slice(1));
expect(sql.sql).toContain('candidate_newer.version > candidate.version');
expect(sql.values).toEqual([[base.id]]);
expect(sql.sql).not.toContain('candidate_newer.patient_id');
expect(sql.sql).not.toContain('content');
},
);
test('简报去重已完成治疗,医生建议取有效头后按当前日期限额,不被旧版占满', async () => {
const rec = (id: string, day: number) => ({
...base,
id,
subjectId: `rec:${id}`,
type: 'recommendation_record',
kind: 'assertion',
status: 'active',
occurredAt: new Date(`2026-10-${String(day).padStart(2, '0')}T10:00:00Z`),
content: { category: '', code: 'K08', name: id },
});
const obsoleteRec = rec('obsolete', 10);
const recCorrection = {
...obsoleteRec,
version: 2,
id: 'rec-corrected',
occurredAt: new Date('2026-09-01T10:00:00Z'),
content: { ...obsoleteRec.content, name: 'corrected-older' },
};
const prisma = fixture([
...history,
obsoleteRec,
recCorrection,
rec('newest', 9),
rec('second', 8),
rec('third', 7),
]);
const runner = {
run: jest
.fn()
.mockResolvedValue({
output: { who: '', history: '', problem: '', hook: '' },
source: 'agent',
invocationId: 'invocation',
}),
};
const brief = new RecallBriefOrchestrator(prisma as never, runner as never, {} as never);
await brief.getOrGenerate(scope, 'plan');
const input = runner.run.mock.calls[0]![1] as DraftRecallBriefInput;
expect(input.treatmentHistory).toHaveLength(1);
expect(input.treatmentHistory[0]!.count).toBe(2);
expect(input.doctorGuidance.map((guidance) => guidance.verbatim)).toEqual([
'newest',
'second',
'third',
]);
expect(prisma.patientFact.findMany).toHaveBeenCalledTimes(1);
expect(prisma.patientFact.findMany.mock.calls[0]![0]).not.toHaveProperty('take');
});
});
......@@ -28,17 +28,19 @@ function runGuard(dbUrl: string, env: NodeJS.ProcessEnv): { code: number; out: s
execFileSync('git', ['init', '-q', dir]);
mkdirSync(join(dir, 'bin'));
writeFileSync(join(dir, 'bin/flock'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); // 本测试只验证数据库形态守卫
writeFileSync(join(dir, 'bin/docker'), '#!/bin/sh\nexit 77\n', { mode: 0o755 }); // 放行后也不访问本机 Docker
mkdirSync(join(dir, 'deploy'), { recursive: true });
mkdirSync(join(dir, 'apps/pac-service'), { recursive: true });
mkdirSync(join(dir, 'apps/pac-web'), { recursive: true });
cpSync(SCRIPT, join(dir, 'deploy/deploy-prod.sh'));
cpSync(join(SCRIPT, '../deploy-mode.sh'), join(dir, 'deploy/deploy-mode.sh'));
writeFileSync(join(dir, 'apps/pac-service/.env'), `DATABASE_URL="${dbUrl}"\n`);
writeFileSync(join(dir, 'apps/pac-web/.env'), '');
try {
const out = execFileSync('bash', ['deploy/deploy-prod.sh', '--no-pull'], {
cwd: dir,
// git/docker 在临时目录里必然失败 —— 无所谓,闸在它们之前,只看闸放不放行
env: { ...process.env, ...env, PATH: `${join(dir, 'bin')}:${process.env.PATH}` },
env: { ...process.env, DEPLOY_BRANCH: 'fixture', ...env, PATH: `${join(dir, 'bin')}:${process.env.PATH}` },
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 30_000,
......
import { PipelineDispatcher } from '../src/modules/sync/pipeline/pipeline-dispatcher.service';
import { ColdImportService } from '../src/modules/sync/cold-import/cold-import.service';
import type { PipelineRunMetrics } from '../src/modules/sync/pipeline/parser-pipeline.service';
const transaction = {
id: 'txn', hostId: 'host', tenantId: 'tenant', patientId: 'new-patient',
action: 'appointment_created', subjectType: 'appointment', subjectId: 'stable',
occurredAt: new Date('2026-10-01T00:00:00Z'), clinicId: 'clinic', sourceEventId: 'event',
};
const metrics: PipelineRunMetrics = {
action: 'appointment_created', parserMatched: true,
factsCreated: 0, factsSuperseded: 1, factsUnchanged: 0, factsEvidenceAppended: 0,
factsStaleSkipped: 0, factsFailed: 0, factRejects: [],
writes: [{ action: 'superseded', factId: 'head', subjectId: 'stable', version: 2, previousPatientId: 'old-patient' }],
};
const emits = { action: transaction.action, subjectType: transaction.subjectType, occurredAtField: 'occurredAt' };
const raw = { externalId: 'stable', patientExternalId: 'patient-ext', occurredAt: transaction.occurredAt };
const patient = { id: transaction.patientId, externalId: 'patient-ext', sourceUnit: '' };
function parser() {
return { runForTransaction: jest.fn().mockResolvedValue(metrics), runForBatch: jest.fn().mockResolvedValue(metrics) };
}
describe('归属更正后的在线双患者刷新', () => {
test.each([false, true])('event dispatcher:重复事件=%s也将移出患者加入实际队列', async (duplicate) => {
const parse = parser();
const queue = { enqueuePersonaRecompute: jest.fn().mockResolvedValue(undefined) };
const prisma = {
patient: { findMany: jest.fn().mockResolvedValue([patient]) },
patientTransaction: {
create: duplicate ? jest.fn().mockRejectedValue({ code: 'P2002' }) : jest.fn().mockResolvedValue(transaction),
findFirst: jest.fn().mockResolvedValue(transaction),
},
};
const dispatcher = new PipelineDispatcher(prisma as never, {synthesize: jest.fn().mockReturnValue(transaction)} as never, parse as never, queue as never);
const result = await dispatcher.dispatchTables({ tables: {appointments: [raw]}, hostId: 'host', tenantId: 'tenant', sourceContext: 'push', emitsResolver: () => emits as never, personaDebounce: true });
expect(result.personaEnqueued).toBe(2);
expect(queue.enqueuePersonaRecompute.mock.calls.map(([job]) => job.patientId).sort()).toEqual(['new-patient', 'old-patient']);
for (const [job, opts] of queue.enqueuePersonaRecompute.mock.calls) {
expect(job).toMatchObject({hostId: 'host', tenantId: 'tenant'});
expect(opts).toEqual({debounce: true});
}
});
test.each(['bulk', 'fallback', 'duplicate-fallback'] as const)(
'raw rows %s:真实 ingestRawTables 返回移出患者与本次患者,无需旧 transaction 出现在 syncLog', async (mode) => {
const parse = parser();
const prisma = {
host: { findFirst: jest.fn().mockResolvedValue({id: 'host'}) },
syncLog: {create: jest.fn().mockResolvedValue({id:'sync'}), update: jest.fn().mockResolvedValue({})},
patient: {findMany: jest.fn().mockResolvedValue([patient])},
patientTransaction: {
createMany: mode === 'bulk' ? jest.fn().mockResolvedValue({count: 1}) : jest.fn().mockRejectedValue({code: 'P2002'}),
create: mode === 'duplicate-fallback' ? jest.fn().mockRejectedValue({code:'P2002'}) : jest.fn().mockResolvedValue(transaction),
findFirst: jest.fn().mockResolvedValue(transaction),
findMany: jest.fn(async ({where}) => where.sourceEventId ? [transaction] : []),
},
};
const assembler = {assemble: jest.fn().mockReturnValue({ rows: [{canonical: raw, rawSource: raw}], emits,
stats: {fetched: 1, failed: 0, mappingMisses: [], suspectFields: []} })};
const cold = new ColdImportService(prisma as never, {synthesize: jest.fn().mockReturnValue({...transaction})} as never, parse as never, assembler as never, {} as never, {} as never, {} as never);
// Stub configuration/loading only; ingestion, buffering, fallback and touched collection execute normally.
const internals = cold as unknown as Record<string, unknown>;
internals.resolveHostDir = jest.fn().mockReturnValue('/unused');
internals.readManifest = jest.fn().mockReturnValue({tenant_id: 'tenant', timezone: 'UTC', amount_unit: 'yuan', transforms: []});
internals.loadAllAssemblers = jest.fn().mockReturnValue([{canonical: 'appointment', primary: {table: 'appointments'}, emits}]);
internals.hydratePushLookupTables = jest.fn().mockResolvedValue(undefined);
internals.detectRawColumnDrift = jest.fn().mockResolvedValue([]);
const result = await cold.ingestRawTables({hostName: 'host-name', source: 'appointments', rows: [raw]});
expect(result.touched.sort((a,b) => a.patientId.localeCompare(b.patientId))).toEqual([
{patientId: 'new-patient', tenantId: 'tenant'}, {patientId: 'old-patient', tenantId: 'tenant'},
]);
expect(result.perResource[0]!.factsSuperseded).toBe(1);
expect(result.perResource[0]).not.toHaveProperty('previousPatientId');
expect(mode === 'bulk' ? parse.runForBatch : parse.runForTransaction).toHaveBeenCalledTimes(1);
},
);
});
import { latestFactsByIdentity } from '../../src/modules/facts/current-fact';
/** Global metadata lookup fixture, including versions belonging to another patient. */
export function currentFactQueryResult(
history: Array<{ id: string; hostId: string; tenantId: string; sourceUnit: string; subjectId: string; version: number }>,
strings: TemplateStringsArray,
values: unknown[],
): Array<{ id: string }> | undefined {
if (!strings.join('').includes('SELECT candidate.id FROM patient_facts candidate')) return undefined;
const requested = new Set(values[0] as string[]);
return latestFactsByIdentity(history).filter((fact) => requested.has(fact.id)).map(({ id }) => ({ id }));
}
import { createHmac, randomUUID } from 'node:crypto';
import { HttpException } from '@nestjs/common';
import { HostServiceHttpError } from '@pac/host-connector';
import { HostWriteController } from '../src/modules/host-write/host-write.controller';
import { BizError } from '../src/common/errors/biz-error';
describe('预约提交是否已经投递的证据', () => {
const key = 'local-appointment-ticket-key'.repeat(2);
const user = { sub: randomUUID() } as any;
const scope = {} as any;
const savedKey = process.env.HOST_PAC_KEY;
beforeEach(() => {
process.env.HOST_PAC_KEY = key;
});
afterEach(() => {
if (savedKey === undefined) delete process.env.HOST_PAC_KEY;
else process.env.HOST_PAC_KEY = savedKey;
});
function fixture(expires = Date.now() + 60_000) {
const requestId = randomUUID();
const input = { patient: { patientId: randomUUID() }, targetOrgRef: 'clinic' };
const encoded = Buffer.from(
JSON.stringify({ requestId, input, userId: user.sub, expires }),
).toString('base64url');
const ticket = `${encoded}.${createHmac('sha256', key).update(encoded).digest('base64url')}`;
const body = {
ticket,
doctorResourceId: 'doctor',
startsAt: '2099-01-01T00:00:00Z',
durationMinutes: 30,
};
const request = jest.fn().mockResolvedValue({ id: randomUUID(), status: 'queued' });
const controller = new HostWriteController({} as any, {} as any, {} as any, {} as any);
jest.spyOn(controller as any, 'client').mockReturnValue({ request });
jest.spyOn(controller as any, 'patient').mockResolvedValue(input.patient);
jest.spyOn(controller as any, 'principal').mockResolvedValue({ kind: 'pac' });
return { controller, body, request, requestId };
}
it('过期票据明确未投递,前端可以刷新票据重新填写', async () => {
const { controller, body, request } = fixture(Date.now() - 1000);
const error = await controller.submit(scope, user, body).catch((value) => value);
expect(error).toBeInstanceOf(HttpException);
expect(error.getStatus()).toBe(403);
expect(error.getResponse().details).toEqual({ appointmentSubmission: 'not_sent' });
expect(request).not.toHaveBeenCalled();
});
it('字段校验拒绝也证明未投递', async () => {
const { controller, body, request } = fixture();
const error = await controller
.submit(scope, user, { ...body, durationMinutes: 0 })
.catch((value) => value);
expect(error).toBeInstanceOf(BizError);
expect(error.details.appointmentSubmission).toBe('not_sent');
expect(request).not.toHaveBeenCalled();
});
it.each([new Error('HTTP response lost'), new HostServiceHttpError(503, 'unavailable')])(
'已调用宿主后的失败不被标成未投递 (%s)',
async (error) => {
const { controller, body, request } = fixture();
request.mockRejectedValue(error);
await expect(controller.submit(scope, user, body)).rejects.toBe(error);
expect((error as any).details).toBeUndefined();
expect(request).toHaveBeenCalledTimes(1);
},
);
it('正常提交保留票据中的幂等请求编号', async () => {
const { controller, body, request, requestId } = fixture();
await expect(controller.submit(scope, user, body)).resolves.toMatchObject({ status: 'queued' });
expect(request).toHaveBeenCalledWith('/operations', expect.objectContaining({ requestId }));
});
});
import { currentFactQueryResult } from './helpers/current-fact-query';
import { Prisma } from '@prisma/client';
import { latestFactsByIdentity } from '../src/modules/facts/current-fact';
import { OpenProfileService } from '../src/modules/open/open-profile.service';
import { OpenFactWindowService } from '../src/modules/open/open-fact-window.service';
import { OpenAppointmentService } from '../src/modules/open/open-appointment.service';
import { PlanAggregateService } from '../src/modules/plan-aggregate/plan-aggregate.service';
import type { TenantScopeContext } from '../src/common/decorators/tenant-scope.decorator';
const scope: TenantScopeContext = {
hostId: 'h',
tenantId: 't',
clinicIds: [],
sourceUnits: [],
userId: 'u',
};
const from = '2026-10-01T00:00:00Z',
to = '2026-10-10T00:00:00Z';
function row(index: number, overrides: Record<string, unknown> = {}) {
const at = new Date('2026-10-02T00:00:00Z');
return {
id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
patient_id: 'p',
hostId: 'h',
tenantId: 't',
sourceUnit: 'u',
subjectId: 'stable',
version: index,
subject_id: 'stable',
type: 'treatment_record',
kind: 'actual',
status: 'fulfilled',
clinic_id: 'c',
occurred_at: at,
planned_for: at,
valid_until: null,
title: null,
summary: null,
content: {},
...overrides,
};
}
const gate = {
clinicsOf: jest.fn().mockReturnValue(null),
admit: jest.fn(async (_scope: unknown, ids: string[]) => new Set(ids)),
};
/**
* 受控查询桩模拟当前头→状态/时间→排序/limit;SQL 契约另行断言。
* 不运行数据库,保留日期更正后新头在窗口外的反例,防止先在窗口内取旧 fulfilled。
*/
function windowQuery(history: ReturnType<typeof row>[], limit: number) {
const queries: Prisma.Sql[] = [];
const query = jest.fn(async (strings: TemplateStringsArray, ...values: unknown[]) => {
const sql = Prisma.sql(strings, ...values);
queries.push(sql);
const anchor = sql.sql.includes('occurred_at AS at') ? 'occurred_at' : 'planned_for';
const facts = sql.sql.includes('f_newer.version > f.version')
? latestFactsByIdentity(history)
: history;
return facts
.filter(
(fact) =>
['active', 'fulfilled'].includes(fact.status) &&
fact[anchor] >= new Date(from) &&
fact[anchor] < new Date(to),
)
.sort((a, b) => a[anchor].getTime() - b[anchor].getTime() || a.id.localeCompare(b.id))
.slice(0, limit)
.map((fact) => ({ ...fact, at: fact[anchor] }));
});
return { query, queries };
}
function assertHead(sql: Prisma.Sql, alias = 'f') {
expect(sql.sql).toContain(`${alias}_newer.version > ${alias}.version`);
for (const key of ['host_id', 'tenant_id', 'source_unit', 'subject_id']) {
expect(sql.sql).toContain(`${alias}_newer.${key} = ${alias}.${key}`);
}
expect(sql.sql).not.toMatch(new RegExp(`${alias}_newer\\.status`));
expect(sql.values).toContain('h');
expect(sql.values).toContain('t');
}
describe('开放事实查询只导出当前版本', () => {
beforeEach(() => {
gate.admit.mockClear();
});
test.each(['occurred', 'planned'] as const)(
'%s 窗口:更正到窗口外/已取消的新版,不能返回窗口内旧 fulfilled',
async (anchor) => {
const outside = new Date('2026-09-30T00:00:00Z');
const { query, queries } = windowQuery(
[
row(1),
row(2, { occurred_at: outside, planned_for: outside }),
row(3, { subjectId: 'cancelled', subject_id: 'cancelled', version: 1 }),
row(4, {
subjectId: 'cancelled',
subject_id: 'cancelled',
version: 2,
status: 'cancelled',
}),
row(5, { subjectId: 'valid', subject_id: 'valid', version: 1 }),
],
10,
);
const service = new OpenFactWindowService({ $queryRaw: query } as never, gate as never);
const result = await service.facts(scope, {
anchor,
types: ['treatment_record'],
from,
to,
limit: 10,
purpose: 'operations',
});
expect(result.items.map((fact) => fact.subjectId)).toEqual(['valid']);
expect(result.nextCursor).toBeNull();
assertHead(queries[0]!);
expect(queries[0]!.values).toEqual(
expect.arrayContaining([new Date(from), new Date(to), ['treatment_record'], 10]),
);
expect(gate.admit).toHaveBeenCalledWith(scope, ['p'], 'operations', '事实窗口');
},
);
test('预约窗口先选头再按 planned_for 取范围,保留原授权/游标契约', async () => {
const outside = new Date('2026-09-30T00:00:00Z');
const { query, queries } = windowQuery(
[
row(1, { type: 'appointment_record' }),
row(2, { type: 'appointment_record', planned_for: outside }),
row(3, {
type: 'appointment_record',
subjectId: 'valid',
subject_id: 'appointment_record:valid',
version: 1,
}),
],
1,
);
const service = new OpenAppointmentService({ $queryRaw: query } as never, gate as never);
const result = await service.window(scope, { from, to, limit: 1, purpose: 'outreach' });
expect(result.items.map((fact) => fact.subjectId)).toEqual(['appointment_record:valid']);
expect(result.nextCursor).toBe('2026-10-02T00:00:00.000Z|appointment_record:valid');
assertHead(queries[0]!);
expect(gate.admit).toHaveBeenCalledWith(scope, ['p'], 'outreach', '预约窗口');
});
test('事实窗口权限过滤后空页仍保留过滤前有效事实的游标', async () => {
const { query } = windowQuery([row(1)], 1);
const restrictive = { ...gate, admit: async () => new Set<string>() };
const service = new OpenFactWindowService({ $queryRaw: query } as never, restrictive as never);
const result = await service.facts(scope, {
anchor: 'occurred',
types: ['treatment_record'],
from,
to,
limit: 1,
purpose: 'outreach',
});
expect(result.items).toEqual([]);
expect(result.nextCursor).toBe(`2026-10-02T00:00:00.000Z|${row(1).id}`);
});
test('患者全貌事实头限制在 ROW_NUMBER/COUNT 前,末诊查询绑定 host/tenant/id', async () => {
const queries: Prisma.Sql[] = [];
const query = jest.fn(async (strings: TemplateStringsArray, ...values: unknown[]) => {
queries.push(Prisma.sql(strings, ...values));
return [];
});
const service = new OpenProfileService(
{ $queryRaw: query, patientProfile: { findMany: async () => [] } } as never,
{} as never,
) as unknown as {
loadFacts(
scope: TenantScopeContext,
ids: string[],
types: string[],
limit: number,
): Promise<unknown>;
loadProfiles(ids: string[], scope: TenantScopeContext): Promise<unknown>;
};
await service.loadFacts(scope, ['p'], ['treatment_record'], 60);
await service.loadProfiles(['p'], scope);
assertHead(queries[0]!);
expect(queries[0]!.sql.indexOf('f_newer.version > f.version')).toBeLessThan(
queries[0]!.sql.indexOf(') t\n'),
);
expect(queries[0]!.sql).toContain('ROW_NUMBER() OVER');
expect(queries[0]!.sql).toContain('COUNT(*) OVER');
assertHead(queries[1]!, 'vis');
for (const sql of queries) expect(sql.values).toContainEqual(['p']);
});
});
describe('计划详情聚合当前事实', () => {
test('已转归属另一患者的终态历史不进入原患者聚合详情', async () => {
const own = row(1);
const moved = row(2);
const query = jest.fn(async (strings: TemplateStringsArray, ...values: unknown[]) =>
currentFactQueryResult([own, moved], strings, values));
const service = new PlanAggregateService(
{ patientFact: { findMany: jest.fn().mockResolvedValue([own]) }, $queryRaw: query } as never,
{} as never,
) as unknown as { loadActiveFacts(scope: TenantScopeContext, id: string): Promise<unknown[]> };
expect(await service.loadActiveFacts(scope, 'p')).toEqual([]);
expect(Prisma.sql(query.mock.calls[0]![0], ...query.mock.calls[0]!.slice(1)).values).toEqual([[own.id]]);
});
test('单患者历史先取头再判状态,跨 unit 版本独立且按当前时间 DESC/NULLS FIRST 返回', async () => {
const historical = [
row(1),
row(2, { occurred_at: new Date('2026-09-30T00:00:00Z') }),
row(3, { subjectId: 'cancelled', version: 1 }),
row(4, { subjectId: 'cancelled', status: 'invalidated', version: 2 }),
row(5, { sourceUnit: 'other', version: 1 }),
row(6, { subjectId: 'null-time', version: 1 }),
].map((fact) => ({
...fact,
occurredAt: fact.subjectId === 'null-time' ? null : fact.occurred_at,
}));
const findMany = jest.fn().mockResolvedValue(historical);
const service = new PlanAggregateService(
{ patientFact: { findMany }, $queryRaw: jest.fn(async (strings: TemplateStringsArray, ...values: unknown[]) =>
currentFactQueryResult(historical, strings, values)) } as never,
{} as never,
) as unknown as {
loadActiveFacts(
scope: TenantScopeContext,
patientId: string,
): Promise<ReturnType<typeof row>[]>;
};
const current = await service.loadActiveFacts(scope, 'p');
expect(current.map((fact) => fact.id)).toEqual([row(6).id, row(5).id, row(2).id]);
expect(findMany.mock.calls[0]![0].where).toEqual({
hostId: 'h',
tenantId: 't',
patientId: 'p',
});
expect(findMany.mock.calls[0]![0].orderBy).toEqual({ occurredAt: 'desc' });
});
});
import { PersonaRecomputeProcessor } from '../src/queues/processors/persona-recompute.processor';
import { FEATURE_EXTRACTOR_PROVIDERS } from '../src/modules/persona/features/feature.registry';
import { personaWriteVerdict } from '../src/modules/persona/persona-diff';
import type {
ActiveFact,
FeatureExtractorContext,
PersonaFeatureDraft,
} from '../src/modules/persona/features/feature.interface';
import type { Job } from 'bullmq';
import type { PersonaRecomputeJob } from '../src/queues/job-payloads';
const now = new Date('2026-10-11T10:00:00Z');
const visit: ActiveFact = {
id: 'visit-1',
subjectId: 'enc:v1',
type: 'encounter_record',
kind: 'actual',
status: 'active',
occurredAt: new Date('2023-09-10T10:00:00Z'),
plannedFor: null,
clinicId: 'clinic-1',
content: {},
};
const diagnosis: ActiveFact = {
...visit,
id: 'dx-1',
subjectId: 'dx:d1',
type: 'diagnosis_record',
content: { code: 'K08', tooth_position: '36', name_zh: '缺失' },
};
const past: ActiveFact = {
...visit,
id: 'appt-1',
subjectId: 'appt:a1',
type: 'appointment_record',
status: 'fulfilled',
plannedFor: visit.occurredAt,
content: { status: 'arrived', arrived_at: visit.occurredAt!.toISOString() },
};
const future: ActiveFact = {
...visit,
id: 'appt-2',
subjectId: 'appt:a2',
type: 'appointment_record',
kind: 'planned',
occurredAt: now,
plannedFor: new Date('2026-10-20T10:00:00Z'),
content: { status: 'scheduled', arrived_at: null },
};
function context(appointments: ActiveFact[]): FeatureExtractorContext {
return {
patient: {
id: 'p1',
externalId: 'p1',
name: null,
phone: null,
gender: 'female',
birthDate: new Date('1980-01-01T00:00:00Z'),
active: true,
},
profile: null,
relations: [],
now,
appointmentsAll: appointments,
factsByType: new Map([
['encounter_record', [visit]],
['diagnosis_record', [diagnosis]],
['appointment_record', appointments],
]),
populationStats: { monetaryQuantiles: [] },
potentialGaps: [
{
factId: diagnosis.id,
code: 'K08',
primaryCode: 'K08',
signalType: 'diagnosis',
tooth: '36',
nameZh: '缺失',
daysSince: 1127,
anchorAt: diagnosis.occurredAt!,
confidence: 1,
},
],
};
}
const job = {
data: { hostId: 'h1', tenantId: 't1', patientId: 'p1', triggeredBy: 'push:appointment' },
attemptsMade: 0,
} as Job<PersonaRecomputeJob>;
function worker(status: string) {
const queue = { enqueuePlanRecompute: jest.fn().mockResolvedValue(undefined) };
const persona = {
recompute: jest
.fn()
.mockResolvedValue({
status,
personaId: 'persona-1',
version: 1,
featureCount: 10,
durationMs: 1,
}),
};
return { processor: new PersonaRecomputeProcessor(persona as never, queue as never), queue };
}
describe('画像结果与召回输入分别判断', () => {
test('真实16个提取器:新增未来预约不改标签,仍触发计划重算以重新执行预约排除闸', async () => {
const extractors = FEATURE_EXTRACTOR_PROVIDERS.map((Type) => new Type());
const extract = (ctx: FeatureExtractorContext) =>
extractors.map((x) => x.extract(ctx)).filter((x): x is PersonaFeatureDraft => x != null);
const before = extract(context([past]));
const after = extract(context([past, future]));
const stored = before.map((feature) => ({
...feature,
score: feature.score ?? null,
data: feature.data ?? null,
}));
expect(personaWriteVerdict(stored, after)).toBe('unchanged');
const { processor, queue } = worker('unchanged');
expect((await processor.process(job)).planEnqueued).toBe(true);
expect(queue.enqueuePlanRecompute).toHaveBeenCalledWith(
expect.objectContaining({ patientId: 'p1' }),
);
});
test.each(['success', 'partial', 'noop', 'refreshed'])(
'%s 正常处理向计划传播',
async (status) => {
const { processor, queue } = worker(status);
expect((await processor.process(job)).planEnqueued).toBe(true);
expect(queue.enqueuePlanRecompute).toHaveBeenCalledTimes(1);
},
);
test('failed 不传播', async () => {
const { processor, queue } = worker('failed');
expect((await processor.process(job)).planEnqueued).toBe(false);
expect(queue.enqueuePlanRecompute).not.toHaveBeenCalled();
});
});
import { TreatmentHistoryFeatureExtractor } from '../src/modules/persona/features/treatment-history.feature';
import { currentFactQueryResult } from './helpers/current-fact-query';
import type { FeatureExtractorContext } from '../src/modules/persona/features/feature.interface';
import { Test } from '@nestjs/testing';
import { PersonaService } from '../src/modules/persona/persona.service';
import { PrismaService } from '../src/prisma/prisma.service';
......@@ -66,12 +69,17 @@ interface Opts {
now: { eventSeq: bigint | null; factAt: Date | null };
/** 当前 active 版本已存的 feature 行 */
storedFeatures?: Array<Record<string, unknown>>;
facts?: Array<Record<string, unknown>>;
globalFacts?: Array<Record<string, unknown>>;
observeContext?: (ctx: FeatureExtractorContext) => void;
draft?: PersonaFeatureDraft | null;
extractor?: FeatureExtractor;
}
function makeHarness(o: Opts) {
const calls = {
personaCreate: 0,
personaCreatedData: [] as Array<Record<string, unknown>>,
personaUpdate: [] as Record<string, unknown>[],
featureUpdate: [] as Record<string, unknown>[],
watermarkQueries: 0,
......@@ -99,8 +107,9 @@ function makeHarness(o: Opts) {
calls.personaUpdate.push(data);
return Promise.resolve({});
}),
create: jest.fn().mockImplementation(() => {
create: jest.fn().mockImplementation(({data}) => {
calls.personaCreate++;
calls.personaCreatedData.push(data);
return Promise.resolve({ id: 'persona-new' });
}),
},
......@@ -120,12 +129,14 @@ function makeHarness(o: Opts) {
upsert: jest.fn().mockResolvedValue({}),
},
// readWatermarks(patient_facts / patient_transactions)与 M 分位查询共用 $queryRaw,靠 SQL 文本分流
$queryRaw: jest.fn().mockImplementation((strings: TemplateStringsArray) => {
$queryRaw: jest.fn().mockImplementation((strings: TemplateStringsArray, ...values: unknown[]) => {
const sql = strings.join('');
if (sql.includes('patient_facts') && sql.includes('MAX(updated_at)')) {
calls.watermarkQueries++;
return Promise.resolve([{ event_seq: o.now.eventSeq, fact_at: o.now.factAt }]);
}
const metadata = currentFactQueryResult((o.globalFacts ?? o.facts ?? []) as never, strings, values);
if (metadata) return Promise.resolve(metadata);
return Promise.resolve([{ q: [] }]); // getMonetaryQuantiles
}),
persona: {
......@@ -140,7 +151,7 @@ function makeHarness(o: Opts) {
create: jest.fn().mockResolvedValue({ id: 'log-1' }),
update: jest.fn().mockResolvedValue({}),
},
patientFact: { findMany: jest.fn().mockResolvedValue([]) },
patientFact: { findMany: jest.fn().mockResolvedValue(o.facts ?? []) },
patientRelation: { findMany: jest.fn().mockResolvedValue([]) },
patientTransaction: {
findFirst: jest.fn().mockImplementation(() => {
......@@ -160,7 +171,14 @@ async function build(o: Opts) {
providers: [
PersonaService,
{ provide: PrismaService, useValue: prisma },
{ provide: FeatureRegistry, useValue: stubRegistry(o.draft === undefined ? RFM() : o.draft) },
{ provide: FeatureRegistry, useValue: (() => {
const registry = o.extractor ? { extractors: [o.extractor] } as FeatureRegistry : stubRegistry(o.draft === undefined ? RFM() : o.draft);
for (const extractor of registry.extractors) {
const extract = extractor.extract.bind(extractor);
extractor.extract = (ctx: FeatureExtractorContext) => { o.observeContext?.(ctx); return extract(ctx); };
}
return registry;
})() },
{ provide: PotentialTreatmentSelector, useValue: { selectForPatient: jest.fn().mockResolvedValue([]) } },
],
}).compile();
......@@ -377,3 +395,45 @@ describe('并发安全 —— CLI 与 cron 抢版本号', () => {
expect(calls.advisoryLocks).toHaveLength(0);
});
});
describe('PersonaService 当前事实读取', () => {
test('归属移出旧患者后水位变化必须实际提取,清除旧标签而非 freshness noop', async () => {
const own = { id: 'own', hostId: PATIENT.hostId, tenantId: PATIENT.tenantId, sourceUnit: 'u', patientId: PATIENT.id,
subjectId: 'stable', version: 1, status: 'fulfilled', type: 'treatment_record', kind: 'actual', occurredAt: T0,
plannedFor: null, clinicId: null, content: { category: 'implant' } };
const observeContext = jest.fn((ctx: FeatureExtractorContext) => expect(ctx.factsByType.get('treatment_record') ?? []).toEqual([]));
const { svc, calls } = await build({ persona: { eventWatermark: 1n, factWatermark: T0 }, now: {eventSeq: 1n, factAt: T1},
extractor: new TreatmentHistoryFeatureExtractor(),
storedFeatures: [{key: 'treatment_history', description: '种植史', score: null, data: {types: ['implant_history'], labels: ['种植史']}, evidence: {factIds: [own.id]}}],
facts: [own], globalFacts: [own, { ...own, id: 'moved', patientId: 'other-patient', version: 2 }], observeContext });
const result = await svc.recompute({patientId: PATIENT.id, source: 'test'});
expect(result.status).not.toBe('noop');
expect(observeContext).toHaveBeenCalled();
expect(result.featureCount).toBe(0);
expect(calls.personaCreatedData[0]).toMatchObject({features: {createMany: {data: []}}});
expect(calls.personaUpdate).toEqual(expect.arrayContaining([expect.objectContaining({supersededAt: expect.any(Date)})]));
});
test('日期更正/取消取完整身份版本头,不会把终态历史重新送给提取器', async () => {
const base = { hostId: PATIENT.hostId, tenantId: PATIENT.tenantId, sourceUnit: 'u',
kind: 'actual', type: 'treatment_record', clinicId: null, plannedFor: null, content: {} };
const corrected = { ...base, id: 'corrected', subjectId: 'stable-treatment', version: 2,
status: 'fulfilled', occurredAt: new Date('2026-09-30T10:00:00Z') };
const { svc, prisma } = await build({ persona: null, now: { eventSeq: 1n, factAt: T1 },
facts: [
{ ...corrected, id: 'old', version: 1, occurredAt: new Date('2026-10-02T10:00:00Z') },
corrected,
{ ...corrected, id: 'cancelled-old', subjectId: 'cancelled-treatment', version: 1 },
{ ...corrected, id: 'cancelled-head', subjectId: 'cancelled-treatment', status: 'cancelled' },
{ ...corrected, id: 'other-unit', sourceUnit: 'other' },
], observeContext: (ctx) => {
expect(ctx.factsByType.get('treatment_record')!.map((f) => f.id).sort()).toEqual(['corrected', 'other-unit']);
},
});
expect((await svc.recompute({ patientId: PATIENT.id, source: 'test' })).status).toBe('success');
const query = (prisma.patientFact.findMany as jest.Mock).mock.calls[0]![0];
expect(query.where.status).toBeUndefined(); // 先过滤终态会把取消前的 fulfilled 复活
expect(query.select).toMatchObject({ hostId: true, tenantId: true, sourceUnit: true, version: true });
});
});
import type { JobsOptions } from 'bullmq';
import { QueueProducer } from '../src/queues/queue-producer.service';
interface Entry {
id: string;
data: Record<string, unknown>;
options: JobsOptions;
state: 'waiting' | 'active' | 'completed' | 'failed';
}
/**
* 无 Redis 的状态机桩:保留终态 job key、按 dedup id 合并等待任务,
* active + keepLastIfActive 保存最后一条并在终态转移时补跑。
* 只验证 Producer 与 BullMQ 契约;不冒充 Redis/Lua 集成测试。
*/
class RetainedQueue {
entries: Entry[] = [];
private pending = new Map<string, Entry>();
private next = new Map<string, Entry>();
async add(_name: string, data: Record<string, unknown>, options: JobsOptions) {
const id = options.jobId!;
const duplicate = this.entries.find((entry) => entry.id === id);
if (duplicate) return duplicate; // BullMQ 固定 jobId 在终态也拒绝新写入
const entry: Entry = { id, data, options, state: 'waiting' };
const key = options.deduplication?.id;
const pending = key ? this.pending.get(key) : undefined;
if (pending) {
if (pending.state === 'active' && options.deduplication?.keepLastIfActive)
this.next.set(key!, entry);
return pending;
}
this.entries.push(entry);
if (key) this.pending.set(key, entry);
return entry;
}
start(entry: Entry) {
entry.state = 'active';
}
finish(entry: Entry, state: 'completed' | 'failed') {
entry.state = state;
const key = entry.options.deduplication?.id;
if (!key) return;
this.pending.delete(key);
const trailing = this.next.get(key);
this.next.delete(key);
if (trailing) {
this.entries.push(trailing);
this.pending.set(key, trailing);
}
}
}
function harness() {
const persona = new RetainedQueue(),
plan = new RetainedQueue(),
asset = new RetainedQueue();
const producer = new QueueProducer(
persona as never,
plan as never,
asset as never,
{} as never,
{} as never,
);
return { producer, persona, plan, asset };
}
const scope = { hostId: 'h1', tenantId: 't1', patientId: 'p1', triggeredBy: 'input:1' };
describe('可变输入重算队列去重', () => {
test.each(['completed', 'failed'] as const)(
'保留 %s 历史后再次输入仍生成新的 plan job',
async (state) => {
const { producer, plan } = harness();
await producer.enqueuePlanRecompute(scope);
plan.finish(plan.entries[0]!, state);
await producer.enqueuePlanRecompute({ ...scope, triggeredBy: 'input:2' });
expect(plan.entries).toHaveLength(2);
expect(plan.entries[1]!.id).not.toBe(plan.entries[0]!.id);
expect(plan.entries[1]!.state).toBe('waiting');
},
);
test('等待时同患者合并,执行中多次更新只保留最后一次尾任务', async () => {
const { producer, plan } = harness();
await producer.enqueuePlanRecompute(scope);
await producer.enqueuePlanRecompute({ ...scope, triggeredBy: 'waiting:update' });
expect(plan.entries).toHaveLength(1);
plan.start(plan.entries[0]!);
await producer.enqueuePlanRecompute({ ...scope, triggeredBy: 'active:1' });
await producer.enqueuePlanRecompute({ ...scope, triggeredBy: 'active:2' });
expect(plan.entries).toHaveLength(1);
plan.finish(plan.entries[0]!, 'completed');
expect(plan.entries).toHaveLength(2);
expect(plan.entries[1]!.data.triggeredBy).toBe('active:2');
expect(plan.entries[1]!.options.deduplication).toEqual({
id: 'plan_p1',
keepLastIfActive: true,
});
});
test('persona 延迟/即时路径共用去重身份,执行后新输入可再排队', async () => {
const { producer, persona } = harness();
await producer.enqueuePersonaRecompute(scope, { debounce: true });
await producer.enqueuePersonaRecompute(scope);
expect(persona.entries).toHaveLength(1);
expect(persona.entries[0]!.options.delay).toBeGreaterThanOrEqual(10_000);
persona.finish(persona.entries[0]!, 'completed');
await producer.enqueuePersonaRecompute(scope);
expect(persona.entries).toHaveLength(2);
expect(persona.entries.map((entry) => entry.options.deduplication?.id)).toEqual([
'persona_p1',
'persona_p1',
]);
});
test('资产更新执行中收到新输入,失败后仍补跑最后一条', async () => {
const { producer, asset } = harness();
await producer.enqueuePlanAssetGenerate({ ...scope, planId: 'plan-1' });
asset.start(asset.entries[0]!);
await producer.enqueuePlanAssetGenerate({
...scope,
planId: 'plan-1',
triggeredBy: 'asset:new',
});
asset.finish(asset.entries[0]!, 'failed');
expect(asset.entries).toHaveLength(2);
expect(asset.entries[1]!.data.triggeredBy).toBe('asset:new');
});
test('不同患者可独立排队', async () => {
const { producer, plan } = harness();
await producer.enqueuePlanRecompute(scope);
await producer.enqueuePlanRecompute({ ...scope, patientId: 'p2' });
expect(plan.entries).toHaveLength(2);
});
});
import { describe, expect, it } from 'vitest';
import { appointmentSubmissionWasNotSent } from './appointment-submission';
describe('预约重试不能把不确定结果当作未提交', () => {
it('只有服务端明确未投递证据允许清理 pending 请求', () => {
const error = Object.assign(new Error('预约确认信息已过期'), {
code: 10107,
details: { appointmentSubmission: 'not_sent' },
});
expect(appointmentSubmissionWasNotSent(error)).toBe(true);
});
it.each([
new Error('network timeout'),
Object.assign(new Error('forbidden'), { code: 10107 }),
Object.assign(new Error('not found'), { code: 10004 }),
Object.assign(new Error('server failed'), { code: 90000 }),
Object.assign(new Error('unknown'), { details: { appointmentSubmission: 'unknown' } }),
Object.assign(new Error('unknown'), { details: null }),
])('缺少明确证据时仍使用原请求编号核对 (%s)', (error) => {
expect(appointmentSubmissionWasNotSent(error)).toBe(false);
});
});
/** 只认服务端明确给出的「本次请求未投递」证据;超时、404 或权限码本身都不能证明。 */
export function appointmentSubmissionWasNotSent(error: unknown): boolean {
if (!(error instanceof Error) || !('details' in error)) return false;
const details = error.details;
return (
typeof details === 'object' &&
details !== null &&
'appointmentSubmission' in details &&
details.appointmentSubmission === 'not_sent'
);
}
......@@ -25,6 +25,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { AppointmentOperationStatus } from './appointment-operation-status';
import { appointmentSubmissionWasNotSent } from './appointment-submission';
export interface AppointmentContext {
mode: 'link' | 'service' | 'disabled' | 'choose_clinic';
......@@ -71,6 +72,7 @@ export function HostAppointmentDialog({
const [error, setError] = useState('');
const [operation, setOperation] = useState<Operation | null>(null);
const [submitted, setSubmitted] = useState(false);
const [contextRevision, setContextRevision] = useState(0);
const bodyRef = useRef<unknown>(null);
const requestRef = useRef<string | null>(null);
const announced = useRef(false);
......@@ -114,7 +116,7 @@ export function HostAppointmentDialog({
return () => {
cancelled = true;
};
}, [patientId, clinic, storageKey]);
}, [patientId, planId, clinic, storageKey, contextRevision]);
const poll = async (id: string) => {
const value = await api.post<Operation>('/pac/v1/host/appointments/status', { id });
......@@ -164,9 +166,21 @@ export function HostAppointmentDialog({
JSON.stringify({ body, requestId: requestRef.current, operation: value }),
);
} catch (e) {
setError(
`${e instanceof Error ? e.message : '未取得提交结果'}。请查询同一请求结果,勿另建预约。`,
);
if (appointmentSubmissionWasNotSent(e)) {
// 明确未投递才释放本次请求;结果未知时仍保留原编号,避免重复预约。
sessionStorage.removeItem(storageKey);
bodyRef.current = null;
requestRef.current = null;
setOperation(null);
setSubmitted(false);
setContext(null);
setContextRevision((value) => value + 1);
setError(`${e instanceof Error ? e.message : '预约未提交'}。请确认信息后重新提交。`);
} else {
setError(
`${e instanceof Error ? e.message : '未取得提交结果'}。请查询同一请求结果,勿另建预约。`,
);
}
} finally {
setBusy(false);
}
......
# main 审查问题修复 · 2026-10-11
本文记录提交及测试部署前的复核、修复和本地验证;后续发布结果另行记录。
分支:`codex/main-review-fixes`。独立工作树实际基于 `origin/main@4e04aa082d68f5693dc3b20cfaaf4b4bfdd3916e`;审查时的本地 `main` 仍是 `3b81549428fc29c59694f5ea86071be7037a62e1`。已核对,这十项涉及的业务代码在两者之间没有变化,并在实际修复基线上重新验证。原工作目录保持 `test`,没有修改其已有业务代码、提交、合并或部署。
上一轮确认的 **10 项问题重新核实后均成立,均已修复代码**。这些结论来自当前源码、实际调用链和本地回归,不代表已核实线上发生次数或受影响人数。
## 问题与修复
| 编号 | 原问题 | 修复后的行为 | 主要验证 |
| --- | --- | --- | --- |
| 1 · P1 | 编码 UUID 绕过宿主组织授权 | 根据 Express 已匹配的路由模板和已解码参数校验资源;缺少必要参数时拒绝。静态通讯录入口仍按原权限执行。 | 实际 Express 路由覆盖普通、编码、二次编码及无效编码路径;已有权限、登录态、开放面回归。 |
| 2 · P1 | AI 回声先到被当成人工回复、提前结束交接 | 持久记录发送尝试与供应商响应,以 `appInfo` 举证来源;响应未返回时暂缓回声,不能证明来源时标记 `unknown`。AI 安抚及未知来源不会关闭交接、打断 AI 轮次或清除待回复状态。实际发送时间用于展示,请求起点及稳定消息 ID 保护新患者等待。 | 回声先到、响应先到、来源补正、安抚消息、请求期间患者先于回声、同秒旧人工/旧群同事回复及已落消息附件 ID 保持回归。 |
| 3 · P2 | 保留终态的固定 jobId 吞掉后续重算 | 每个任务实例使用唯一 ID,按患者/计划使用稳定去重键;等待时合并,执行期间保留最后一次更新,终态后允许新任务。 | completed/failed 保留、等待合并、执行尾任务及不同主体契约;核对锁定的 BullMQ 5.76.10 Lua 实现。 |
| 4 · P2 | 同内容更新没有推进源水位 | 单写、批写及批内虚拟头同步推进较大 `sourceUpdatedAt`,原子合并证据。完整身份事务锁覆盖读取、裁决和写入;批量锁固定排序。写入时间单调推进,避免锁等待造成画像水位回退;患者归属纳入幂等判断。 | T10/A → T30/A → 晚到 T20/B,单写/批写混合并发、同 transaction 更新、批内重复、跨来源身份及归属更正。 |
| 5 · P2 | 画像标签不变,漏掉召回事实变化 | 正常处理的 `success/partial/unchanged/refreshed/noop` 均触发计划重新判断;失败不传播。未来预约、主档跟进等变化不再依赖画像标签升版本。 | 16 个真实提取器证明未来预约不改标签,仍向计划传播;各结果状态回归。 |
| 6 · P2 | 旧 fulfilled 被当成当前事实 | 按 `(hostId, tenantId, sourceUnit, subjectId)` 选择全局最高版本后,再应用状态、时间、类型、诊所和分页条件。覆盖画像、金额分位、两套缺口 SQL、召回闸、末诊归属、开放接口、详情及 AI 输入。当前日期重排后再取最近记录;归属更正同时传播移出、移入患者重算。 | 日期订正、新头取消/失效、跨身份、窗口外新头、分页计数/游标、详情和话术输入;单写/批写/批内 A→B→C,在线两入口及真实治疗史提取器移除旧患者种植史回归。 |
| 7 · P2 | 生命周期退避让旧掉线越过新上线 | 同账号生命周期按原始 `(created_at, id)` 排序;队首退避时后续事件等待,不能仅按可变的 `scheduled_at` 选择。中转入口同样设置生命周期 route;迁移只给有同租户真实账号归属的旧待处理事件补路由。 | 实际 claim CTE 的本地内存 SQL,以及旧掉线退避、新上线等待、队首终态放行、存量回填归属与幂等回归。 |
| 8 · P2 | 新消息绕过账号限流冷却 | 保存账号冷却截止;文字、媒体、位置和提醒投递前共同检查,人工发送也受供应商冷却限制。重复限流只延长截止,不提前原有排期。 | 旧队列延期、新消息持有、截止恢复及重复限流单调性回归。 |
| 9 · P2 | 预约明确拒绝后永久卡在查询模式 | 仅在调用宿主前的明确拒绝返回 `appointmentSubmission=not_sent`;前端释放该 pending、刷新票据并恢复表单。实际投递后的超时或错误保留原 requestId 核对。 | 票据过期、校验失败、网络结果未知、宿主错误、正常提交和客户端判据。 |
| 10 · P2 | 轮询目的地占满回调批次 | 在 LIMIT 前筛选已配置回调的合法目的地;无 URL 的记录保留审计,不阻塞其他目的地。后来启用回调仍可投递历史记录。 | 五条 PAC 轮询结果不阻塞 AI 回调,后来启用 PAC 回调及完全未配置回调。 |
历史事实及按 factId 倒读的计划举证保留;修复当前态读取,不删除终态历史。金额单位、宿主时区、治疗规则和组织授权范围没有调整。
补查确认宿主能够订正同一源记录的患者引用,事实版本身份不包含 patientId。因此不能只在单患者历史中选最高版本。六个 ORM 消费者先取得局部候选,再以候选事实 ID 核验全局版本头,只读取存在性,不读取另一患者正文。归属更正会创建新事实版本,并在同一事务推进旧患者的事实写入水位,使画像 freshness 判断感知移出;dispatcher 与 raw rows 两条在线摄入路径都将新、旧患者纳入重算,覆盖批写、逐条降级及重复恢复。离线导入策略保持原样。
## 消息来源的事实边界
供应商没有保证请求 traceId 会关联到回声;不能按正文相同推断来源。HTTP 响应完全丢失时,无法凭现有契约绝对证明是哪一方发送。修复采用固定的发送尝试窗口保守识别,将无法举证的记录显示为“来源待确认”,保留人工交接;明确拒绝及窗口之后的人工消息仍按人工处理。
已有错误标为人工的历史消息不做推测式批量改写。历史画像、召回计划和 AI 缓存也没有自动批量回填;如需修正存量结果,应在发布验证后按实际受影响范围重新计算或重新生成。
## 验证记录
| 验证 | 结果 |
| --- | --- |
| PAC 后端全量 Jest | 最终 127 个套件,2,158 项全部通过,包含补查的归属更正回归。 |
| AI 后端全量 Jest | 191 个套件,3,296 项通过;另有 11 个套件、85 项跳过,未计入已验证范围。后补的生命周期迁移回归单独执行,1 个套件、1 项通过。 |
| 宿主服务全量 Jest | 3 个套件,24 项通过。 |
| PAC 前端全量 Vitest | 3 个文件,56 项通过,单 worker 执行。 |
| 部署脚本本地 fixture | 串行 45 项全部通过;首次默认并行有 1 项子进程触及 10 秒超时,复跑没有修改超时或部署脚本。 |
| 类型与构建 | PAC、AI、宿主服务和两个前端类型检查通过;三个后端构建、两个前端 Next 构建及共享依赖构建通过。 |
| 静态检查 | AI CI 范围 ESLint 零错误,保留 45 条既有 warning;新增可靠性文件零 warning。边界检查通过,实际两库 URL 差异检查因未配置而跳过。`git diff --check` 通过。 |
| Prisma | AI schema 校验、client 生成及无需连接数据库的 schema diff 通过;新列、索引、外键及 onUpdate 定义与迁移核对一致。 |
全量验证同时修正旧测试 fixture:部署守卫补齐 `deploy-mode.sh` 并隔离 Docker,授权守卫 fixture 补真实路由及已解码参数,clinic-guard 更新为实际当前头语义,权限测试改静态导入。没有修改部署脚本或生产权限规则。
## 发布所需迁移
新增 AI 数据库迁移:`apps/ai-service/prisma/migrations/20261011140000_channel_delivery_reliability/migration.sql`。
- 账号新增可空的限流截止,旧账号正常投递语义兼容。
- 新增发送尝试和回声元数据,记录供应商来源证据与可信排序;使用租户、账号、会话复合外键约束归属。
- 消息来源允许显式 `unknown`,生命周期队首增加索引。
- 存量生命周期回填只补 `pending/processing` 且 route 为空的账号事件,按租户和真实账号连接;保留原始排期、租约和终态历史。
Prisma 校验与 client 生成只使用合法形状的本地占位 URL。本次没有执行数据库迁移、重推宿主数据、连接真实通道、访问服务器或部署。
PostgreSQL 实际锁行为、迁移执行、查询计划及 Redis/Lua 集成性能仍需在发布验证环境确认。本地 SQL 回归使用内存 SQLite,只适配类型、时间、JSON 和 PostgreSQL 特有语法;不把它当成 PostgreSQL 或真实通道端到端验证。
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