Initial commit: HealthCarePregnant project documentation and platform
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.git
|
||||
.DS_Store
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,21 @@
|
||||
# PCM 后端环境变量示例(复制为 .env 后填写真实值,勿提交 .env)
|
||||
PORT=3000
|
||||
|
||||
# 鉴权令牌签名密钥(生产必须为强随机值,支持轮换;勿用默认值)
|
||||
AUTH_SECRET=change-me-to-a-strong-random-secret
|
||||
|
||||
# 数据库(PostgreSQL)。
|
||||
# - 不设置 → 后端使用内存仓储(dev/测试,重启数据丢失)。
|
||||
# - 设置 → 启用持久化(启动时幂等建表)。本地示例:
|
||||
# DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/pcm
|
||||
|
||||
# 字段级加密密钥(AES-256-GCM,32 字节;hex64 或 base64)。
|
||||
# 生产经 KMS/Secrets 注入并轮换;用于迁移 patient/observation 等敏感字段。
|
||||
# 生成示例:node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
# FIELD_ENCRYPTION_KEY=
|
||||
|
||||
# 大模型 / RAG(按所选服务填写)
|
||||
LLM_API_KEY=
|
||||
LLM_API_BASE=
|
||||
|
||||
# 注意:密钥类变量仅放入本地 .env,不得提交仓库
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
coverage/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,25 @@
|
||||
# PCM 后端生产镜像(多阶段构建)
|
||||
# 1) build:安装全部依赖并编译 TypeScript → dist
|
||||
# 2) runtime:仅保留生产依赖与 dist,非 root 运行
|
||||
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
# 仅保留生产依赖,缩小运行镜像
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
FROM node:20-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
# 拷贝生产依赖与编译产物(归属 node 用户)
|
||||
COPY --from=build --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --from=build --chown=node:node /app/dist ./dist
|
||||
COPY --from=build --chown=node:node /app/package.json ./package.json
|
||||
EXPOSE 3000
|
||||
USER node
|
||||
# busybox wget 做容器健康检查(见 docker-compose.yml)
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
+9411
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "pcm-backend",
|
||||
"version": "0.1.0",
|
||||
"description": "PCM 孕产个案管理平台 后端 API",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main.js",
|
||||
"seed": "ts-node --transpile-only src/seed.ts",
|
||||
"lint": "eslint \"src/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"seed": "ts-node src/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/config": "^3.2.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"pg": "^8.21.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
"@nestjs/schematics": "^10.1.0",
|
||||
"@nestjs/testing": "^10.4.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^20.14.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.16.0",
|
||||
"@typescript-eslint/parser": "^7.16.0",
|
||||
"eslint": "^8.57.0",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.0",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { PatientModule } from './modules/patient/patient.module';
|
||||
import { AnalysisModule } from './modules/analysis/analysis.module';
|
||||
import { ObservationModule } from './modules/observation/observation.module';
|
||||
import { CaseflowModule } from './modules/caseflow/caseflow.module';
|
||||
import { NotificationModule } from './modules/notification/notification.module';
|
||||
import { ReminderModule } from './modules/reminder/reminder.module';
|
||||
import { RedflagModule } from './modules/redflag/redflag.module';
|
||||
import { KnowledgeModule } from './modules/knowledge/knowledge.module';
|
||||
import { AiModule } from './modules/ai/ai.module';
|
||||
import { AuditModule } from './modules/audit/audit.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { DispositionModule } from './modules/disposition/disposition.module';
|
||||
import { FollowupModule } from './modules/followup/followup.module';
|
||||
import { ReferralModule } from './modules/referral/referral.module';
|
||||
import { EmotionModule } from './modules/emotion/emotion.module';
|
||||
import { WorklistModule } from './modules/worklist/worklist.module';
|
||||
import { JwtAuthGuard } from './common/auth/jwt-auth.guard';
|
||||
import { CapabilitiesGuard } from './common/auth/capabilities.guard';
|
||||
import { DatabaseModule } from './common/db/database.module';
|
||||
|
||||
/**
|
||||
* 应用根模块。
|
||||
* 业务模块(auth/patient/observation/analysis/...)将随阶段逐步接入,
|
||||
* 详见 4-arch-PCM.md 第 3 节模块划分。
|
||||
*
|
||||
* 全局守卫(顺序很重要):
|
||||
* 1) JwtAuthGuard —— 认证:校验令牌并注入 req.user(@Public 放行)。
|
||||
* 2) CapabilitiesGuard —— 授权:按 @RequireCaps 校验 RBAC 能力,越权拒绝并审计。
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
DatabaseModule,
|
||||
AuditModule,
|
||||
NotificationModule,
|
||||
AuthModule,
|
||||
HealthModule,
|
||||
PatientModule,
|
||||
AnalysisModule,
|
||||
ObservationModule,
|
||||
CaseflowModule,
|
||||
ReminderModule,
|
||||
RedflagModule,
|
||||
KnowledgeModule,
|
||||
AiModule,
|
||||
DispositionModule,
|
||||
FollowupModule,
|
||||
ReferralModule,
|
||||
EmotionModule,
|
||||
WorklistModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: CapabilitiesGuard },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { CapabilitiesGuard } from './capabilities.guard';
|
||||
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||
import { CAPS_KEY } from './capabilities.decorator';
|
||||
import { signToken } from '../../modules/auth/token';
|
||||
import { getAuthSecret } from './auth-secret';
|
||||
import { AuditService } from '../../modules/audit/audit.service';
|
||||
import { AuthedRequest } from './request-user';
|
||||
|
||||
function ctxWith(req: AuthedRequest): ExecutionContext {
|
||||
return {
|
||||
switchToHttp: () => ({ getRequest: () => req }),
|
||||
getHandler: () => undefined,
|
||||
getClass: () => undefined,
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
/** 构造一个 reflector,按 key 返回预设元数据。 */
|
||||
function reflectorWith(meta: Record<string, unknown>): Reflector {
|
||||
return {
|
||||
getAllAndOverride: (key: string) => meta[key],
|
||||
} as unknown as Reflector;
|
||||
}
|
||||
|
||||
function bearer(token: string): AuthedRequest {
|
||||
return { headers: { authorization: `Bearer ${token}` } };
|
||||
}
|
||||
|
||||
describe('JwtAuthGuard(认证守卫)', () => {
|
||||
const validToken = (): string =>
|
||||
signToken(
|
||||
{ sub: 'u1', role: 'case_manager', exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
getAuthSecret(),
|
||||
);
|
||||
|
||||
it('@Public 端点跳过认证', () => {
|
||||
const guard = new JwtAuthGuard(reflectorWith({ [IS_PUBLIC_KEY]: true }));
|
||||
const req: AuthedRequest = { headers: {} };
|
||||
expect(guard.canActivate(ctxWith(req))).toBe(true);
|
||||
});
|
||||
|
||||
it('有效令牌 → 通过并注入 req.user', () => {
|
||||
const guard = new JwtAuthGuard(reflectorWith({}));
|
||||
const req = bearer(validToken());
|
||||
expect(guard.canActivate(ctxWith(req))).toBe(true);
|
||||
expect(req.user).toEqual({ id: 'u1', role: 'case_manager' });
|
||||
});
|
||||
|
||||
it('缺少令牌 → 401', () => {
|
||||
const guard = new JwtAuthGuard(reflectorWith({}));
|
||||
expect(() => guard.canActivate(ctxWith({ headers: {} }))).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('无效令牌 → 401', () => {
|
||||
const guard = new JwtAuthGuard(reflectorWith({}));
|
||||
expect(() => guard.canActivate(ctxWith(bearer('garbage.token')))).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('过期令牌 → 401', () => {
|
||||
const expired = signToken(
|
||||
{ sub: 'u1', role: 'patient', exp: Math.floor(Date.now() / 1000) - 10 },
|
||||
getAuthSecret(),
|
||||
);
|
||||
const guard = new JwtAuthGuard(reflectorWith({}));
|
||||
expect(() => guard.canActivate(ctxWith(bearer(expired)))).toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CapabilitiesGuard(授权守卫)', () => {
|
||||
let audit: AuditService;
|
||||
|
||||
beforeEach(() => {
|
||||
audit = new AuditService();
|
||||
});
|
||||
|
||||
it('无能力声明 → 放行', () => {
|
||||
const guard = new CapabilitiesGuard(reflectorWith({}), audit);
|
||||
const req: AuthedRequest = { headers: {}, user: { id: 'u1', role: 'patient' } };
|
||||
expect(guard.canActivate(ctxWith(req))).toBe(true);
|
||||
});
|
||||
|
||||
it('具备能力 → 放行', () => {
|
||||
const guard = new CapabilitiesGuard(reflectorWith({ [CAPS_KEY]: ['careplan:write'] }), audit);
|
||||
const req: AuthedRequest = { headers: {}, user: { id: 'cm', role: 'case_manager' } };
|
||||
expect(guard.canActivate(ctxWith(req))).toBe(true);
|
||||
});
|
||||
|
||||
it('越权 → 403 并记录审计 access:deny', async () => {
|
||||
const guard = new CapabilitiesGuard(reflectorWith({ [CAPS_KEY]: ['audit:read'] }), audit);
|
||||
const req: AuthedRequest = { headers: {}, user: { id: 'mom', role: 'patient' } };
|
||||
expect(() => guard.canActivate(ctxWith(req))).toThrow(ForbiddenException);
|
||||
const denials = await audit.query({ action: 'access:deny' });
|
||||
expect(denials).toHaveLength(1);
|
||||
expect(denials[0].actorId).toBe('mom');
|
||||
});
|
||||
|
||||
it('孕妇可自助建档/自设提醒/查看本人预警', () => {
|
||||
const guard = (caps: string[]): CapabilitiesGuard =>
|
||||
new CapabilitiesGuard(reflectorWith({ [CAPS_KEY]: caps }), audit);
|
||||
const req: AuthedRequest = { headers: {}, user: { id: 'mom', role: 'patient' } };
|
||||
expect(guard(['patient:create']).canActivate(ctxWith(req))).toBe(true);
|
||||
expect(guard(['reminder:dispatch']).canActivate(ctxWith(req))).toBe(true);
|
||||
expect(guard(['alert:read']).canActivate(ctxWith(req))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 鉴权密钥单一来源(AuthService 与 JwtAuthGuard 共用,避免漂移)。
|
||||
* 生产环境必须通过环境变量注入强随机密钥,并支持轮换。
|
||||
*/
|
||||
export function getAuthSecret(): string {
|
||||
return process.env.AUTH_SECRET ?? 'dev-secret-change-me';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { Action } from '../../modules/auth/rbac';
|
||||
|
||||
export const CAPS_KEY = 'pcm:caps';
|
||||
|
||||
/**
|
||||
* 声明访问该端点所需的能力(RBAC action)。
|
||||
* 多个能力时需全部满足。由 CapabilitiesGuard 校验。
|
||||
*/
|
||||
export const RequireCaps = (...caps: Action[]): MethodDecorator & ClassDecorator =>
|
||||
SetMetadata(CAPS_KEY, caps);
|
||||
@@ -0,0 +1,45 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { CAPS_KEY } from './capabilities.decorator';
|
||||
import { AuthedRequest } from './request-user';
|
||||
import { Action, can } from '../../modules/auth/rbac';
|
||||
import { AuditService } from '../../modules/audit/audit.service';
|
||||
|
||||
/**
|
||||
* 全局授权守卫(T-2.2 分级权限 / 越权拒绝并审计)。
|
||||
* 读取 @RequireCaps 声明的能力,校验当前用户角色是否具备(能力级 RBAC)。
|
||||
* 越权访问被拒绝(403)并写入审计(access:deny)。
|
||||
*
|
||||
* 注:仅做能力级(action)授权;记录级(仅本人/负责个案/绑定孕妇)
|
||||
* 由各服务结合上下文校验(见 rbac.ts 说明),属已知后续项。
|
||||
*/
|
||||
@Injectable()
|
||||
export class CapabilitiesGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const caps = this.reflector.getAllAndOverride<Action[]>(CAPS_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!caps || caps.length === 0) return true;
|
||||
|
||||
const req = context.switchToHttp().getRequest<AuthedRequest>();
|
||||
const user = req.user;
|
||||
|
||||
// 未认证(理论上 JwtAuthGuard 已拦截;双保险)
|
||||
if (!user) {
|
||||
throw new ForbiddenException('未认证');
|
||||
}
|
||||
|
||||
const missing = caps.filter((cap) => !can(user.role, cap));
|
||||
if (missing.length > 0) {
|
||||
this.audit.record(user.id, 'access:deny', missing.join(','));
|
||||
throw new ForbiddenException('无权限执行该操作');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { AuthedRequest, RequestUser } from './request-user';
|
||||
|
||||
/** 取出经认证的当前用户(由 JwtAuthGuard 注入)。 */
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): RequestUser | undefined => {
|
||||
const req = ctx.switchToHttp().getRequest<AuthedRequest>();
|
||||
return req.user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { verifyToken } from '../../modules/auth/token';
|
||||
import { getAuthSecret } from './auth-secret';
|
||||
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||
import { AuthedRequest, RequestUser } from './request-user';
|
||||
import { Role } from '../../modules/auth/rbac';
|
||||
|
||||
/**
|
||||
* 全局认证守卫(T-2.1 会话安全)。
|
||||
* 校验 Authorization: Bearer <token> 的签名与过期,注入 req.user。
|
||||
* @Public 端点跳过认证(登录/注册/健康检查)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const req = context.switchToHttp().getRequest<AuthedRequest>();
|
||||
const token = extractBearer(req);
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('缺少访问令牌');
|
||||
}
|
||||
|
||||
const payload = verifyToken(token, getAuthSecret());
|
||||
if (!payload) {
|
||||
throw new UnauthorizedException('令牌无效或已过期');
|
||||
}
|
||||
|
||||
const user: RequestUser = { id: payload.sub, role: payload.role as Role };
|
||||
req.user = user;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function extractBearer(req: AuthedRequest): string | null {
|
||||
const header = req.headers['authorization'];
|
||||
const value = Array.isArray(header) ? header[0] : header;
|
||||
if (!value) return null;
|
||||
const [scheme, token] = value.split(' ');
|
||||
if (scheme?.toLowerCase() !== 'bearer' || !token) return null;
|
||||
return token.trim();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'pcm:isPublic';
|
||||
|
||||
/** 标注端点为公开(跳过认证),用于登录/注册/健康检查。 */
|
||||
export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Role } from '../../modules/auth/rbac';
|
||||
|
||||
/** 经 JwtAuthGuard 校验后注入 request 的当前用户标识。 */
|
||||
export interface RequestUser {
|
||||
id: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
/** 带已认证用户的请求(guard 注入)。 */
|
||||
export interface AuthedRequest {
|
||||
user?: RequestUser;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { decryptField, encryptField, fieldKeyFromEnv, isEncrypted } from './field-crypto';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
describe('field-crypto(字段级加密 AES-256-GCM)', () => {
|
||||
const key = randomBytes(32);
|
||||
|
||||
it('加密后可解密还原(round-trip)', () => {
|
||||
const plain = '空腹血糖 6.2 mmol/L · 孕妇隐私字段';
|
||||
const token = encryptField(plain, key);
|
||||
expect(token).not.toContain(plain);
|
||||
expect(isEncrypted(token)).toBe(true);
|
||||
expect(decryptField(token, key)).toBe(plain);
|
||||
});
|
||||
|
||||
it('相同明文每次密文不同(随机 IV)', () => {
|
||||
const a = encryptField('same', key);
|
||||
const b = encryptField('same', key);
|
||||
expect(a).not.toBe(b);
|
||||
expect(decryptField(a, key)).toBe('same');
|
||||
expect(decryptField(b, key)).toBe('same');
|
||||
});
|
||||
|
||||
it('被篡改的密文解密失败(GCM 认证)', () => {
|
||||
const token = encryptField('secret', key);
|
||||
const parts = token.split(':');
|
||||
const tampered = Buffer.from(parts[3], 'base64');
|
||||
tampered[0] ^= 0xff;
|
||||
parts[3] = tampered.toString('base64');
|
||||
expect(() => decryptField(parts.join(':'), key)).toThrow();
|
||||
});
|
||||
|
||||
it('错误密钥解密失败', () => {
|
||||
const token = encryptField('secret', key);
|
||||
expect(() => decryptField(token, randomBytes(32))).toThrow();
|
||||
});
|
||||
|
||||
it('非法密钥长度抛错', () => {
|
||||
expect(() => encryptField('x', randomBytes(16))).toThrow();
|
||||
});
|
||||
|
||||
it('密文格式非法抛错', () => {
|
||||
expect(() => decryptField('not-a-token', key)).toThrow('密文格式无效');
|
||||
});
|
||||
|
||||
it('fieldKeyFromEnv:未配置返回 null;hex64 解析为 32 字节', () => {
|
||||
const prev = process.env.FIELD_ENCRYPTION_KEY;
|
||||
delete process.env.FIELD_ENCRYPTION_KEY;
|
||||
expect(fieldKeyFromEnv()).toBeNull();
|
||||
process.env.FIELD_ENCRYPTION_KEY = randomBytes(32).toString('hex');
|
||||
expect(fieldKeyFromEnv()?.length).toBe(32);
|
||||
if (prev === undefined) delete process.env.FIELD_ENCRYPTION_KEY;
|
||||
else process.env.FIELD_ENCRYPTION_KEY = prev;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 字段级加密工具(T-1.2 / NFR-1 存储加密)。
|
||||
* AES-256-GCM:随机 IV + 认证标签,防篡改。纯函数,便于单测。
|
||||
*
|
||||
* 令牌格式:`v1:<base64 iv>:<base64 authTag>:<base64 ciphertext>`
|
||||
* 密钥:32 字节(256 bit)。生产经 KMS/Secrets 注入并轮换。
|
||||
*
|
||||
* 适用:迁移 patient/observation 等含敏感健康数据的仓储时,对敏感字段加密入库。
|
||||
* 口令为不可逆哈希(password.ts),无需此处加密。
|
||||
*/
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
|
||||
const ALGO = 'aes-256-gcm';
|
||||
const IV_LEN = 12; // GCM 推荐 96-bit IV
|
||||
const VERSION = 'v1';
|
||||
|
||||
export function encryptField(plaintext: string, key: Buffer): string {
|
||||
assertKey(key);
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const cipher = createCipheriv(ALGO, key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return [VERSION, iv.toString('base64'), tag.toString('base64'), ciphertext.toString('base64')].join(
|
||||
':',
|
||||
);
|
||||
}
|
||||
|
||||
export function decryptField(token: string, key: Buffer): string {
|
||||
assertKey(key);
|
||||
const parts = token.split(':');
|
||||
if (parts.length !== 4 || parts[0] !== VERSION) {
|
||||
throw new Error('密文格式无效');
|
||||
}
|
||||
const iv = Buffer.from(parts[1], 'base64');
|
||||
const tag = Buffer.from(parts[2], 'base64');
|
||||
const ciphertext = Buffer.from(parts[3], 'base64');
|
||||
const decipher = createDecipheriv(ALGO, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
/** 是否为已加密令牌(用于读路径的兼容判断)。 */
|
||||
export function isEncrypted(value: string): boolean {
|
||||
return typeof value === 'string' && value.startsWith(`${VERSION}:`) && value.split(':').length === 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从环境变量读取字段加密密钥(hex 64 / base64)。未配置返回 null。
|
||||
* 生产:经 KMS/Secrets 注入强随机 32 字节密钥并支持轮换。
|
||||
*/
|
||||
export function fieldKeyFromEnv(): Buffer | null {
|
||||
const raw = process.env.FIELD_ENCRYPTION_KEY?.trim();
|
||||
if (!raw) return null;
|
||||
const key = /^[0-9a-fA-F]{64}$/.test(raw) ? Buffer.from(raw, 'hex') : Buffer.from(raw, 'base64');
|
||||
if (key.length !== 32) {
|
||||
throw new Error('FIELD_ENCRYPTION_KEY 必须为 32 字节(hex64 或 base64)');
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function assertKey(key: Buffer): void {
|
||||
if (!Buffer.isBuffer(key) || key.length !== 32) {
|
||||
throw new Error('字段加密密钥必须为 32 字节');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { FieldSealer } from './field-sealer';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { isEncrypted } from './field-crypto';
|
||||
|
||||
describe('FieldSealer(敏感字段封装)', () => {
|
||||
const payload = { name: '小雅', historyGdm: true, value: 6.2 };
|
||||
|
||||
it('配置密钥 → 密文入库,可还原', () => {
|
||||
const sealer = new FieldSealer(randomBytes(32));
|
||||
const sealed = sealer.seal(payload);
|
||||
expect(isEncrypted(sealed)).toBe(true);
|
||||
expect(sealed).not.toContain('小雅');
|
||||
expect(sealer.open(sealed)).toEqual(payload);
|
||||
});
|
||||
|
||||
it('未配置密钥 → 明文 JSON(dev),可还原', () => {
|
||||
const sealer = new FieldSealer(null);
|
||||
const sealed = sealer.seal(payload);
|
||||
expect(isEncrypted(sealed)).toBe(false);
|
||||
expect(sealer.open(sealed)).toEqual(payload);
|
||||
expect(sealer.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('密文数据但无密钥 → 解密报错', () => {
|
||||
const enc = new FieldSealer(randomBytes(32)).seal(payload);
|
||||
const noKey = new FieldSealer(null);
|
||||
expect(() => noKey.open(enc)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { decryptField, encryptField, fieldKeyFromEnv, isEncrypted } from './field-crypto';
|
||||
|
||||
/**
|
||||
* 字段封装器(T-1.2):把敏感字段集合序列化为单个"密封"字符串入库。
|
||||
* - 配置 FIELD_ENCRYPTION_KEY → AES-256-GCM 加密(密文入库);
|
||||
* - 未配置 → 明文 JSON 入库(仅 dev;生产必须配置密钥)。
|
||||
* 读路径按令牌格式自动识别加解密,兼容历史明文。
|
||||
*/
|
||||
export class FieldSealer {
|
||||
constructor(private readonly key: Buffer | null) {}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this.key !== null;
|
||||
}
|
||||
|
||||
seal(payload: unknown): string {
|
||||
const json = JSON.stringify(payload);
|
||||
return this.key ? encryptField(json, this.key) : json;
|
||||
}
|
||||
|
||||
open<T>(sealed: string): T {
|
||||
if (isEncrypted(sealed)) {
|
||||
if (!this.key) {
|
||||
throw new Error('数据为密文但未配置 FIELD_ENCRYPTION_KEY,无法解密');
|
||||
}
|
||||
return JSON.parse(decryptField(sealed, this.key)) as T;
|
||||
}
|
||||
return JSON.parse(sealed) as T;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从环境变量构造封装器(FIELD_ENCRYPTION_KEY 缺省则为明文模式)。 */
|
||||
export function createSealerFromEnv(): FieldSealer {
|
||||
return new FieldSealer(fieldKeyFromEnv());
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Global, Inject, Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Pool } from 'pg';
|
||||
import { PG_POOL, isDatabaseEnabled } from './db.tokens';
|
||||
import { runSchemaBootstrap } from './schema';
|
||||
|
||||
/**
|
||||
* 全局数据库模块(T-1.2 / T-0.3 持久化)。
|
||||
* - 设置 DATABASE_URL → 创建 pg 连接池,启动时幂等建表;
|
||||
* - 未设置 → 提供 null,业务模块回退内存仓储(dev/测试)。
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: PG_POOL,
|
||||
useFactory: (): Pool | null => {
|
||||
if (!isDatabaseEnabled()) return null;
|
||||
return new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [PG_POOL],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger('Database');
|
||||
|
||||
constructor(@Inject(PG_POOL) private readonly pool: Pool | null) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
if (!this.pool) {
|
||||
this.logger.log('DATABASE_URL 未设置:使用内存仓储(dev/测试)');
|
||||
return;
|
||||
}
|
||||
await runSchemaBootstrap(this.pool);
|
||||
this.logger.log('PostgreSQL 已连接,schema 就绪');
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.pool) await this.pool.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** PG 连接池注入令牌;未启用 DB 时该提供者为 null。 */
|
||||
export const PG_POOL = 'PG_POOL';
|
||||
|
||||
/** 是否启用 PostgreSQL 持久化(由 DATABASE_URL 决定)。 */
|
||||
export function isDatabaseEnabled(): boolean {
|
||||
return Boolean(process.env.DATABASE_URL && process.env.DATABASE_URL.trim());
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Pool } from 'pg';
|
||||
|
||||
/**
|
||||
* 幂等 schema 引导(MVP)。生产应改用受控迁移工具(如 node-pg-migrate / Flyway)。
|
||||
* 仅在 DATABASE_URL 启用时由 DatabaseModule 调用。
|
||||
*/
|
||||
export async function runSchemaBootstrap(pool: Pool): Promise<void> {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id uuid PRIMARY KEY,
|
||||
username text UNIQUE NOT NULL,
|
||||
password_hash text NOT NULL,
|
||||
role text NOT NULL,
|
||||
consent_signed boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id uuid PRIMARY KEY,
|
||||
actor_id text NOT NULL,
|
||||
action text NOT NULL,
|
||||
target text,
|
||||
at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log (actor_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log (action);`);
|
||||
|
||||
// 孕妇档案:敏感 PII/PHI 加密入 enc 列;仅保留非敏感可查询元数据为列(T-1.2 字段级加密)
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS patients (
|
||||
id uuid PRIMARY KEY,
|
||||
patient_no text,
|
||||
initial_risk_level text NOT NULL,
|
||||
enc text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
// 既有库幂等补列(人类可读编号)
|
||||
await pool.query(`ALTER TABLE patients ADD COLUMN IF NOT EXISTS patient_no text;`);
|
||||
await pool.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_patient_no ON patients (patient_no);`);
|
||||
|
||||
// 观测值:value 等敏感测量加密入 enc 列;指标/孕周/时间等元数据为列以支持检索排序
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS observations (
|
||||
id uuid PRIMARY KEY,
|
||||
patient_id uuid NOT NULL,
|
||||
indicator text NOT NULL,
|
||||
unit text NOT NULL,
|
||||
measured_at timestamptz NOT NULL,
|
||||
source text NOT NULL,
|
||||
qc_status text NOT NULL,
|
||||
gestational_weeks int NOT NULL,
|
||||
enc text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_obs_patient ON observations (patient_id);`);
|
||||
|
||||
// 预警:value/规则/说明等敏感内容加密入 enc;level/status/indicator 留列以供检索
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id uuid PRIMARY KEY,
|
||||
patient_id uuid NOT NULL,
|
||||
observation_id uuid,
|
||||
indicator text NOT NULL,
|
||||
level text NOT NULL,
|
||||
status text NOT NULL,
|
||||
enc text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_alert_patient ON alerts (patient_id);`);
|
||||
|
||||
// 个案:流转历史(含原因)加密入 enc;阶段/状态/风险/负责管理师留列
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS cases (
|
||||
id uuid PRIMARY KEY,
|
||||
patient_id uuid NOT NULL,
|
||||
case_manager_id text,
|
||||
stage text NOT NULL,
|
||||
status text NOT NULL,
|
||||
risk_level text NOT NULL,
|
||||
enc text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_case_patient ON cases (patient_id);`);
|
||||
|
||||
// 照护计划:目标/干预等临床内容加密入 enc;case/patient/status 留列
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS care_plans (
|
||||
id uuid PRIMARY KEY,
|
||||
case_id uuid NOT NULL,
|
||||
patient_id uuid NOT NULL,
|
||||
status text NOT NULL,
|
||||
enc text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_plan_case ON care_plans (case_id);`);
|
||||
|
||||
// 提醒:文案加密入 enc;类型/时间等留列
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS reminders (
|
||||
id uuid PRIMARY KEY,
|
||||
patient_id uuid NOT NULL,
|
||||
type text NOT NULL,
|
||||
effective_type text NOT NULL,
|
||||
adjusted_for_risk boolean NOT NULL DEFAULT false,
|
||||
scheduled_at timestamptz NOT NULL,
|
||||
enc text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_reminder_patient ON reminders (patient_id);`);
|
||||
|
||||
// 知识库:权威可公开内容(A-1),明文存储以支持全文/关键词检索
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS knowledge_items (
|
||||
id uuid PRIMARY KEY,
|
||||
category text NOT NULL,
|
||||
title text NOT NULL,
|
||||
content text NOT NULL,
|
||||
keywords text[] NOT NULL DEFAULT '{}',
|
||||
source text NOT NULL,
|
||||
authority text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
// 处置单(REQ-D1):标题/来源ID/动作明细加密入 enc;状态/风险/确认/创建人等留列供检索与门控
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS dispositions (
|
||||
id uuid PRIMARY KEY,
|
||||
case_id uuid NOT NULL,
|
||||
patient_id uuid NOT NULL,
|
||||
source_type text NOT NULL,
|
||||
status text NOT NULL,
|
||||
risk_level text NOT NULL,
|
||||
requires_confirmation boolean NOT NULL DEFAULT false,
|
||||
closure_outcome text,
|
||||
supersedes_id uuid,
|
||||
created_by text NOT NULL,
|
||||
confirmed_by text,
|
||||
enc text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
closed_at timestamptz
|
||||
);
|
||||
`);
|
||||
await pool.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_disposition_patient ON dispositions (patient_id);`,
|
||||
);
|
||||
|
||||
// 跟进项(T-D.2):目标/复测关联加密入 enc;指标/状态/到期留列
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS followups (
|
||||
id uuid PRIMARY KEY,
|
||||
disposition_id uuid NOT NULL,
|
||||
patient_id uuid NOT NULL,
|
||||
indicator text NOT NULL,
|
||||
status text NOT NULL,
|
||||
outcome text,
|
||||
enc text NOT NULL,
|
||||
due_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
evaluated_at timestamptz
|
||||
);
|
||||
`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_followup_patient ON followups (patient_id);`);
|
||||
|
||||
// 转诊/会诊(T-D.3):诊断陈述/回复/意见加密入 enc;状态/指派医生/紧急度留列供检索
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS referrals (
|
||||
id uuid PRIMARY KEY,
|
||||
patient_id uuid NOT NULL,
|
||||
disposition_id uuid,
|
||||
type text NOT NULL,
|
||||
status text NOT NULL,
|
||||
urgency text NOT NULL,
|
||||
from_manager_id text NOT NULL,
|
||||
to_doctor_id text NOT NULL,
|
||||
enc text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_referral_patient ON referrals (patient_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_referral_doctor ON referrals (to_doctor_id);`);
|
||||
|
||||
// 情绪打卡(T-D.4):日记/日记密文入 enc;分值/信号状态留列
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS emotions (
|
||||
id uuid PRIMARY KEY,
|
||||
patient_id uuid NOT NULL,
|
||||
score int NOT NULL,
|
||||
status text NOT NULL,
|
||||
enc text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_emotion_patient ON emotions (patient_id);`);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 端到端闭环集成测试(T-9.2 / PRD §5.1)。
|
||||
* 串联:建档 → 录入偏高血糖 → 质控 → 规则分析 → 预警 → 个案流转
|
||||
* → AI 决策建议 → 知识问答 → 红旗急症。
|
||||
*/
|
||||
import { PatientService } from '../modules/patient/patient.service';
|
||||
import { InMemoryPatientRepository } from '../modules/patient/patient.repository';
|
||||
import { AnalysisService } from '../modules/analysis/analysis.service';
|
||||
import { InMemoryAlertRepository } from '../modules/analysis/alert.repository';
|
||||
import { CaseflowService } from '../modules/caseflow/caseflow.service';
|
||||
import { InMemoryCaseflowRepository } from '../modules/caseflow/caseflow.repository';
|
||||
import { ObservationService } from '../modules/observation/observation.service';
|
||||
import { InMemoryObservationRepository } from '../modules/observation/observation.repository';
|
||||
import { KnowledgeService } from '../modules/knowledge/knowledge.service';
|
||||
import { InMemoryKnowledgeRepository } from '../modules/knowledge/knowledge.repository';
|
||||
import { AiService } from '../modules/ai/ai.service';
|
||||
import { NotificationService } from '../modules/notification/notification.service';
|
||||
import { RedflagService } from '../modules/redflag/redflag.service';
|
||||
import { FollowupService } from '../modules/followup/followup.service';
|
||||
import { InMemoryFollowupRepository } from '../modules/followup/followup.repository';
|
||||
|
||||
describe('GDM 闭环集成', () => {
|
||||
let patientService: PatientService;
|
||||
let analysisService: AnalysisService;
|
||||
let caseflowService: CaseflowService;
|
||||
let observationService: ObservationService;
|
||||
let aiService: AiService;
|
||||
let knowledgeService: KnowledgeService;
|
||||
let redflagService: RedflagService;
|
||||
let notification: NotificationService;
|
||||
|
||||
beforeEach(() => {
|
||||
patientService = new PatientService(new InMemoryPatientRepository());
|
||||
analysisService = new AnalysisService(new InMemoryAlertRepository());
|
||||
caseflowService = new CaseflowService(new InMemoryCaseflowRepository());
|
||||
observationService = new ObservationService(
|
||||
new InMemoryObservationRepository(),
|
||||
patientService,
|
||||
analysisService,
|
||||
caseflowService,
|
||||
new FollowupService(new InMemoryFollowupRepository()),
|
||||
);
|
||||
knowledgeService = new KnowledgeService(new InMemoryKnowledgeRepository());
|
||||
aiService = new AiService(knowledgeService, analysisService, caseflowService);
|
||||
notification = new NotificationService();
|
||||
redflagService = new RedflagService(notification, caseflowService);
|
||||
});
|
||||
|
||||
it('完整闭环:偏高血糖触发预警、个案流转与需人工确认的建议', async () => {
|
||||
// 1. 建档(高龄 → medium 基线)
|
||||
const patient = await patientService.create({
|
||||
name: '小雅',
|
||||
age: 36,
|
||||
heightCm: 160,
|
||||
prePregnancyWeightKg: 60,
|
||||
lmp: '2026-01-01',
|
||||
});
|
||||
|
||||
// 2. 录入偏高空腹血糖 → 质控通过 → 分析 → 预警
|
||||
const rec = await observationService.record(patient.id, {
|
||||
indicator: 'fasting_glucose',
|
||||
value: 5.6,
|
||||
});
|
||||
expect(rec.observation.qcStatus).toBe('accepted');
|
||||
expect(rec.alert?.level).toBe('medium');
|
||||
|
||||
// 3. 个案被预警驱动(自动开案)
|
||||
const c = await caseflowService.getCaseByPatient(patient.id);
|
||||
expect(c.riskLevel).toBe('medium');
|
||||
|
||||
// 4. AI 决策建议(中风险 → 需人工确认)
|
||||
const reco = await aiService.recommendForPatient(patient.id);
|
||||
expect(reco.requiresHumanConfirmation).toBe(true);
|
||||
expect(reco.rationale.length).toBeGreaterThan(0);
|
||||
|
||||
// 5. 知识问答(带溯源、不超纲)
|
||||
await knowledgeService.create({
|
||||
category: 'guideline',
|
||||
title: '妊娠期糖尿病饮食',
|
||||
content: '控制碳水、少食多餐。',
|
||||
keywords: ['血糖', '饮食'],
|
||||
source: '权威指南',
|
||||
authority: 'authoritative',
|
||||
});
|
||||
const ans = await aiService.ask('血糖偏高如何饮食');
|
||||
expect(ans.grounded).toBe(true);
|
||||
expect(ans.citations[0].source).toBe('权威指南');
|
||||
});
|
||||
|
||||
it('红旗急症:立即就医提示并升级个案为高风险', async () => {
|
||||
const patient = await patientService.create({ name: '小李', age: 30, lmp: '2026-01-01' });
|
||||
const result = await redflagService.evaluate(patient.id, {
|
||||
systolicBp: 165,
|
||||
symptoms: ['severe_headache'],
|
||||
});
|
||||
expect(result.triggered).toBe(true);
|
||||
expect(result.patientAdvice).toContain('就医');
|
||||
|
||||
// 孕妇收到紧急通知
|
||||
const urgent = notification.findByRecipient(patient.id).filter((m) => m.urgent);
|
||||
expect(urgent.length).toBeGreaterThan(0);
|
||||
|
||||
// 个案升级为高风险
|
||||
const c = await caseflowService.getCaseByPatient(patient.id);
|
||||
expect(c.riskLevel).toBe('high');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* 端到端场景演练(T-9.2 / PRD §3.2 核心场景 S1–S7、§5.1 闭环、§7 上线判据)。
|
||||
*
|
||||
* 在服务层串联各模块,覆盖 V1 可上线场景的实质闭环;
|
||||
* 标注为 V2/V3 的能力(趋势预测、游戏化)以 it.todo 记录,不在 V1 断言。
|
||||
*
|
||||
* 复用既有内存仓储与服务,构造共享依赖的系统实例(makeSystem)。
|
||||
*/
|
||||
import { PatientService } from '../modules/patient/patient.service';
|
||||
import { InMemoryPatientRepository } from '../modules/patient/patient.repository';
|
||||
import { AnalysisService } from '../modules/analysis/analysis.service';
|
||||
import { InMemoryAlertRepository } from '../modules/analysis/alert.repository';
|
||||
import { CaseflowService } from '../modules/caseflow/caseflow.service';
|
||||
import { InMemoryCaseflowRepository } from '../modules/caseflow/caseflow.repository';
|
||||
import { ObservationService } from '../modules/observation/observation.service';
|
||||
import { InMemoryObservationRepository } from '../modules/observation/observation.repository';
|
||||
import { KnowledgeService } from '../modules/knowledge/knowledge.service';
|
||||
import { InMemoryKnowledgeRepository } from '../modules/knowledge/knowledge.repository';
|
||||
import { AiService } from '../modules/ai/ai.service';
|
||||
import { NotificationService } from '../modules/notification/notification.service';
|
||||
import { RedflagService } from '../modules/redflag/redflag.service';
|
||||
import { ReminderService } from '../modules/reminder/reminder.service';
|
||||
import { FollowupService } from '../modules/followup/followup.service';
|
||||
import { InMemoryFollowupRepository } from '../modules/followup/followup.repository';
|
||||
|
||||
function makeSystem() {
|
||||
const notification = new NotificationService();
|
||||
const followupService = new FollowupService(new InMemoryFollowupRepository());
|
||||
const patientService = new PatientService(new InMemoryPatientRepository());
|
||||
const analysisService = new AnalysisService(new InMemoryAlertRepository());
|
||||
const caseflowService = new CaseflowService(new InMemoryCaseflowRepository());
|
||||
const observationService = new ObservationService(
|
||||
new InMemoryObservationRepository(),
|
||||
patientService,
|
||||
analysisService,
|
||||
caseflowService,
|
||||
followupService,
|
||||
);
|
||||
const knowledgeService = new KnowledgeService(new InMemoryKnowledgeRepository());
|
||||
const aiService = new AiService(knowledgeService, analysisService, caseflowService);
|
||||
const redflagService = new RedflagService(notification, caseflowService);
|
||||
const reminderService = new ReminderService(patientService, notification);
|
||||
return {
|
||||
notification,
|
||||
patientService,
|
||||
analysisService,
|
||||
caseflowService,
|
||||
observationService,
|
||||
knowledgeService,
|
||||
aiService,
|
||||
redflagService,
|
||||
reminderService,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PRD §3.2 核心场景端到端演练(T-9.2)', () => {
|
||||
describe('S1 居家测血糖/血压后自动分析(REQ-1/3)', () => {
|
||||
it('血糖录入 → 质控通过 → 规则分析 → 可追溯预警', async () => {
|
||||
const sys = makeSystem();
|
||||
const patient = await sys.patientService.create({ name: '小雅', age: 30, lmp: '2026-01-01' });
|
||||
|
||||
const rec = await sys.observationService.record(patient.id, {
|
||||
indicator: 'fasting_glucose',
|
||||
value: 5.6,
|
||||
});
|
||||
expect(rec.observation.qcStatus).toBe('accepted');
|
||||
expect(rec.alert).not.toBeNull();
|
||||
expect(rec.alert?.level).toBe('medium');
|
||||
// 可解释 / 可追溯(NFR-3):命中规则 + 关联观测值
|
||||
expect(rec.alert?.ruleIds.length).toBeGreaterThan(0);
|
||||
expect(rec.alert?.observationId).toBe(rec.observation.id);
|
||||
});
|
||||
|
||||
it('血压录入 → 升高生成预警', async () => {
|
||||
const sys = makeSystem();
|
||||
const patient = await sys.patientService.create({ name: '小雅', age: 30, lmp: '2026-01-01' });
|
||||
const rec = await sys.observationService.record(patient.id, {
|
||||
indicator: 'systolic_bp',
|
||||
value: 150,
|
||||
});
|
||||
expect(rec.observation.qcStatus).toBe('accepted');
|
||||
expect(rec.alert?.level).toBe('medium');
|
||||
});
|
||||
|
||||
it('不可信数值 → 质控拦截,不驱动高风险结论(C-5)', async () => {
|
||||
const sys = makeSystem();
|
||||
const patient = await sys.patientService.create({ name: '小雅', age: 30, lmp: '2026-01-01' });
|
||||
const rec = await sys.observationService.record(patient.id, {
|
||||
indicator: 'fasting_glucose',
|
||||
value: 50, // 超出生理合理范围
|
||||
});
|
||||
expect(rec.observation.qcStatus).toBe('rejected');
|
||||
expect(rec.alert).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('S2 GDM 风险孕妇的日常管理闭环(REQ-6/9/10)', () => {
|
||||
it('建档→预警→自动开案→指派→流转→AI建议(需人工确认)→照护计划→提醒', async () => {
|
||||
const sys = makeSystem();
|
||||
// 既往 GDM → 基线 medium
|
||||
const patient = await sys.patientService.create({
|
||||
name: '小雅',
|
||||
age: 31,
|
||||
lmp: '2026-01-01',
|
||||
historyGdm: true,
|
||||
});
|
||||
expect(patient.initialRiskLevel).toBe('medium');
|
||||
|
||||
// 录入偏高血糖 → 预警 → 预警驱动开案
|
||||
const rec = await sys.observationService.record(patient.id, {
|
||||
indicator: 'fasting_glucose',
|
||||
value: 6.2,
|
||||
});
|
||||
expect(rec.alert).not.toBeNull();
|
||||
|
||||
const c = await sys.caseflowService.getCaseByPatient(patient.id);
|
||||
expect(c.status).toBe('open');
|
||||
expect(c.riskLevel).toBe('medium');
|
||||
|
||||
// 指派管理师 + 推进阶段
|
||||
await sys.caseflowService.assignManager(c.id, 'cm-1');
|
||||
const advanced = await sys.caseflowService.advanceStage(c.id, 'assessment', '评估');
|
||||
expect(advanced.stage).toBe('assessment');
|
||||
expect(advanced.caseManagerId).toBe('cm-1');
|
||||
|
||||
// AI 决策建议:中风险 → 必须人工确认(REQ-10.3)
|
||||
const reco = await sys.aiService.recommendForPatient(patient.id);
|
||||
expect(reco.requiresHumanConfirmation).toBe(true);
|
||||
expect(reco.actions.length).toBeGreaterThan(0);
|
||||
|
||||
// 照护计划
|
||||
const plan = await sys.caseflowService.createCarePlan(c.id, {
|
||||
goals: ['空腹血糖 < 5.1 mmol/L'],
|
||||
interventions: [{ kind: 'lifestyle', description: '饮食控制 + 餐后散步' }],
|
||||
followUpFrequency: 'weekly',
|
||||
});
|
||||
expect(plan.goals.length).toBe(1);
|
||||
|
||||
// 提醒下发
|
||||
const reminder = await sys.reminderService.dispatch({ patientId: patient.id, type: 'measurement' });
|
||||
expect(reminder.effectiveType).toBe('measurement');
|
||||
expect(sys.notification.findByRecipient(patient.id).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3 子痫前期红旗症状旁路(REQ-5)', () => {
|
||||
it('危急组合 → 即时就医提示 + 通知管理师/医生 + 升级高风险', async () => {
|
||||
const sys = makeSystem();
|
||||
const patient = await sys.patientService.create({ name: '小雅', age: 30, lmp: '2026-01-01' });
|
||||
|
||||
const result = await sys.redflagService.evaluate(patient.id, {
|
||||
systolicBp: 165,
|
||||
diastolicBp: 112,
|
||||
symptoms: ['severe_headache'],
|
||||
});
|
||||
expect(result.triggered).toBe(true);
|
||||
expect(result.hits.map((h) => h.ruleId)).toContain('RF-PREECLAMPSIA');
|
||||
expect(result.patientAdvice).toContain('就医');
|
||||
|
||||
// 通知:孕妇 + 管理师 + 医生,均为紧急
|
||||
const msgs = sys.notification.findByRecipient(patient.id);
|
||||
const audiences = msgs.filter((m) => m.urgent).map((m) => m.audience);
|
||||
expect(audiences).toEqual(expect.arrayContaining(['patient', 'case_manager', 'physician']));
|
||||
|
||||
// 个案升级为高风险
|
||||
const c = await sys.caseflowService.getCaseByPatient(patient.id);
|
||||
expect(c.riskLevel).toBe('high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('S4 深夜疑问"我能吃这个吗"(REQ-7)', () => {
|
||||
it('命中知识 → 带溯源作答;无依据 → 不超纲并建议就医', async () => {
|
||||
const sys = makeSystem();
|
||||
await sys.knowledgeService.create({
|
||||
category: 'guideline',
|
||||
title: '孕期饮食与血糖',
|
||||
content: '控制精制碳水、少量多餐;水果适量并计入总量。',
|
||||
keywords: ['饮食', '血糖', '水果', '能吃'],
|
||||
source: '某权威指南',
|
||||
authority: 'authoritative',
|
||||
});
|
||||
|
||||
const grounded = await sys.aiService.ask('孕期能吃水果吗');
|
||||
expect(grounded.grounded).toBe(true);
|
||||
expect(grounded.citations.length).toBeGreaterThan(0);
|
||||
expect(grounded.citations[0].source).toBe('某权威指南');
|
||||
|
||||
const ungrounded = await sys.aiService.ask('明天会下雨吗');
|
||||
expect(ungrounded.grounded).toBe(false);
|
||||
expect(ungrounded.citations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('S5 趋势预警/预测(REQ-3.5/4)', () => {
|
||||
it('纵向观测按孕周累积,构成趋势分析的数据基础', async () => {
|
||||
const sys = makeSystem();
|
||||
const patient = await sys.patientService.create({ name: '小雅', age: 30, lmp: '2026-01-01' });
|
||||
await sys.observationService.record(patient.id, { indicator: 'fasting_glucose', value: 4.8 });
|
||||
await sys.observationService.record(patient.id, { indicator: 'fasting_glucose', value: 5.0 });
|
||||
const series = await sys.observationService.list(patient.id);
|
||||
expect(series.length).toBe(2);
|
||||
// 每条带孕周上下文,支撑后续时序趋势
|
||||
expect(series.every((o) => typeof o.gestationalWeeks === 'number')).toBe(true);
|
||||
});
|
||||
|
||||
// 趋势异常检测(REQ-3.5,V2)与预测模型(REQ-4,V3,需回顾性+前瞻性验证)超出 V1 范围
|
||||
it.todo('S5 时序趋势异常检测(REQ-3.5)为 V2');
|
||||
it.todo('S5 趋势预测模型(REQ-4)为 V3,需经验证');
|
||||
});
|
||||
|
||||
describe('S6 提升坚持度、缓解焦虑(REQ-9)', () => {
|
||||
it('高风险孕妇运动提醒自动替换为休息(REQ-9.2 安全约束)', async () => {
|
||||
const sys = makeSystem();
|
||||
// 多胎 + 既往GDM → score 4 → 基线高风险
|
||||
const patient = await sys.patientService.create({
|
||||
name: '小雅',
|
||||
age: 31,
|
||||
lmp: '2026-01-01',
|
||||
multipleGestation: true,
|
||||
historyGdm: true,
|
||||
});
|
||||
expect(patient.initialRiskLevel).toBe('high');
|
||||
|
||||
const reminder = await sys.reminderService.dispatch({ patientId: patient.id, type: 'exercise' });
|
||||
expect(reminder.effectiveType).toBe('rest');
|
||||
expect(reminder.adjustedForRisk).toBe(true);
|
||||
});
|
||||
|
||||
it('非高风险孕妇运动提醒保持运动', async () => {
|
||||
const sys = makeSystem();
|
||||
const patient = await sys.patientService.create({ name: '小雅', age: 28, lmp: '2026-01-01' });
|
||||
const reminder = await sys.reminderService.dispatch({ patientId: patient.id, type: 'exercise' });
|
||||
expect(reminder.effectiveType).toBe('exercise');
|
||||
expect(reminder.adjustedForRisk).toBe(false);
|
||||
});
|
||||
|
||||
// 游戏化(积分/徽章/小游戏,REQ-9.3/9.4)为 V2
|
||||
it.todo('S6 游戏化激励(REQ-9.3/9.4)为 V2');
|
||||
});
|
||||
|
||||
describe('S7 管理师 PC 端批量管理(REQ-13.3)', () => {
|
||||
it('多名孕妇入列,提供风险分层作为工作台排序依据', async () => {
|
||||
const sys = makeSystem();
|
||||
await sys.patientService.create({ name: '低风险', age: 28, lmp: '2026-01-01' });
|
||||
await sys.patientService.create({ name: '中风险', age: 31, lmp: '2026-01-01', historyGdm: true });
|
||||
await sys.patientService.create({
|
||||
name: '高风险',
|
||||
age: 36,
|
||||
lmp: '2026-01-01',
|
||||
multipleGestation: true,
|
||||
historyPih: true,
|
||||
});
|
||||
|
||||
const list = await sys.patientService.list();
|
||||
expect(list.length).toBe(3);
|
||||
// 每名孕妇均带初始风险分层(admin-web 工作列表据此按 高→中→低 排序)
|
||||
const levels = list.map((p) => p.initialRiskLevel).sort();
|
||||
expect(levels).toEqual(['high', 'low', 'medium']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api');
|
||||
const port = process.env.PORT ?? 3000;
|
||||
await app.listen(port);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`PCM backend listening on http://localhost:${port}/api`);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { AiService } from './ai.service';
|
||||
import { Recommendation } from './recommendation';
|
||||
import { QaAnswer } from '../knowledge/knowledge.types';
|
||||
import { RequireCaps } from '../../common/auth/capabilities.decorator';
|
||||
|
||||
@Controller('ai')
|
||||
export class AiController {
|
||||
constructor(private readonly ai: AiService) {}
|
||||
|
||||
/** 聊天问答 */
|
||||
@Get('ask')
|
||||
@RequireCaps('knowledge:ask')
|
||||
ask(@Query('q') q: string): Promise<QaAnswer> {
|
||||
return this.ai.ask(q);
|
||||
}
|
||||
|
||||
/** 个案决策建议(给管理师) */
|
||||
@Get('patients/:patientId/recommendation')
|
||||
@RequireCaps('alert:read')
|
||||
recommend(@Param('patientId') patientId: string): Promise<Recommendation> {
|
||||
return this.ai.recommendForPatient(patientId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AiService } from './ai.service';
|
||||
import { AiController } from './ai.controller';
|
||||
import { KnowledgeModule } from '../knowledge/knowledge.module';
|
||||
import { AnalysisModule } from '../analysis/analysis.module';
|
||||
import { CaseflowModule } from '../caseflow/caseflow.module';
|
||||
|
||||
@Module({
|
||||
imports: [KnowledgeModule, AnalysisModule, CaseflowModule],
|
||||
controllers: [AiController],
|
||||
providers: [AiService],
|
||||
exports: [AiService],
|
||||
})
|
||||
export class AiModule {}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { generateRecommendation, Recommendation } from './recommendation';
|
||||
import { AnalysisService } from '../analysis/analysis.service';
|
||||
import { CaseflowService } from '../caseflow/caseflow.service';
|
||||
import { KnowledgeService } from '../knowledge/knowledge.service';
|
||||
import { QaAnswer } from '../knowledge/knowledge.types';
|
||||
|
||||
/**
|
||||
* AI 交互中枢(REQ-10)。
|
||||
* - 问答:委托知识库 RAG(带溯源、不超纲);
|
||||
* - 决策建议:基于个案当前风险与预警生成,高风险须人工确认(医生兜底)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
constructor(
|
||||
private readonly knowledge: KnowledgeService,
|
||||
private readonly analysis: AnalysisService,
|
||||
private readonly caseflow: CaseflowService,
|
||||
) {}
|
||||
|
||||
/** 聊天问答(孕妇端主入口)(REQ-7.2/10.1) */
|
||||
ask(question: string): Promise<QaAnswer> {
|
||||
return this.knowledge.ask(question);
|
||||
}
|
||||
|
||||
/** 为个案生成决策建议(REQ-10.2/10.3)。 */
|
||||
async recommendForPatient(patientId: string): Promise<Recommendation> {
|
||||
const c = await this.caseflow.getCaseByPatient(patientId);
|
||||
const alerts = await this.analysis.listAlerts(patientId);
|
||||
const openMessages = alerts
|
||||
.filter((a) => a.status === 'open')
|
||||
.flatMap((a) => a.messages);
|
||||
|
||||
return generateRecommendation({
|
||||
riskLevel: c.riskLevel,
|
||||
alertMessages: openMessages,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { generateRecommendation } from './recommendation';
|
||||
|
||||
describe('generateRecommendation(AI 决策建议)', () => {
|
||||
it('低风险 → 无需人工确认', () => {
|
||||
const r = generateRecommendation({ riskLevel: 'low', alertMessages: [] });
|
||||
expect(r.requiresHumanConfirmation).toBe(false);
|
||||
});
|
||||
|
||||
it('中风险 → 需人工确认且带依据', () => {
|
||||
const r = generateRecommendation({ riskLevel: 'medium', alertMessages: ['血糖偏高'] });
|
||||
expect(r.requiresHumanConfirmation).toBe(true);
|
||||
expect(r.rationale).toContain('血糖偏高');
|
||||
});
|
||||
|
||||
it('高风险 → 必须人工确认(医生兜底)', () => {
|
||||
const r = generateRecommendation({ riskLevel: 'high', alertMessages: ['血压重度升高'] });
|
||||
expect(r.requiresHumanConfirmation).toBe(true);
|
||||
expect(r.actions.some((a) => a.includes('医生'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* AI 决策建议生成(REQ-10.2/10.3/10.4)。纯逻辑,便于测试。
|
||||
* 关键约束:高风险/急症的建议必须人工确认(requiresHumanConfirmation=true),
|
||||
* AI 不自动执行临床决策(医生兜底)。每条建议带可解释依据。
|
||||
*/
|
||||
export type RiskLevel = 'low' | 'medium' | 'high';
|
||||
|
||||
export interface RecommendationInput {
|
||||
riskLevel: RiskLevel;
|
||||
/** 触发建议的预警说明(溯源) */
|
||||
alertMessages: string[];
|
||||
}
|
||||
|
||||
export interface Recommendation {
|
||||
/** 建议动作(供管理师参考) */
|
||||
actions: string[];
|
||||
/** 必须人工确认后才可执行 */
|
||||
requiresHumanConfirmation: boolean;
|
||||
/** 可解释依据 */
|
||||
rationale: string[];
|
||||
}
|
||||
|
||||
export function generateRecommendation(input: RecommendationInput): Recommendation {
|
||||
const rationale = [...input.alertMessages];
|
||||
|
||||
if (input.riskLevel === 'high') {
|
||||
return {
|
||||
actions: ['尽快联系孕妇核实', '安排医生评估', '考虑转诊/进一步检查'],
|
||||
requiresHumanConfirmation: true,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.riskLevel === 'medium') {
|
||||
return {
|
||||
actions: ['加强监测频率', '提供针对性生活方式/饮食指导', '安排近期随访'],
|
||||
requiresHumanConfirmation: true,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
actions: ['维持常规监测与关怀'],
|
||||
requiresHumanConfirmation: false,
|
||||
rationale: rationale.length ? rationale : ['当前指标处于正常范围'],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Alert } from './alert.types';
|
||||
|
||||
/** 预警仓储抽象(内存实现,后续接入 DB)。 */
|
||||
export abstract class AlertRepository {
|
||||
abstract save(alert: Alert): Promise<Alert>;
|
||||
abstract findByPatient(patientId: string): Promise<Alert[]>;
|
||||
abstract findAll(): Promise<Alert[]>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InMemoryAlertRepository extends AlertRepository {
|
||||
private readonly store: Alert[] = [];
|
||||
|
||||
async save(alert: Alert): Promise<Alert> {
|
||||
this.store.push(alert);
|
||||
return alert;
|
||||
}
|
||||
|
||||
async findByPatient(patientId: string): Promise<Alert[]> {
|
||||
return this.store.filter((a) => a.patientId === patientId);
|
||||
}
|
||||
|
||||
async findAll(): Promise<Alert[]> {
|
||||
return [...this.store];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { RiskLevel } from './rule-engine';
|
||||
import { IndicatorType } from './indicator';
|
||||
|
||||
export type AlertStatus = 'open' | 'acknowledged' | 'resolved';
|
||||
|
||||
/** 预警事件(REQ-3.3)。可解释、可追溯到观测值与规则(NFR-3)。 */
|
||||
export interface Alert {
|
||||
id: string;
|
||||
patientId: string;
|
||||
observationId: string;
|
||||
indicator: IndicatorType;
|
||||
value: number;
|
||||
level: RiskLevel;
|
||||
/** 命中规则 ID 列表(溯源) */
|
||||
ruleIds: string[];
|
||||
/** 人类可读说明 */
|
||||
messages: string[];
|
||||
status: AlertStatus;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { AnalysisService } from './analysis.service';
|
||||
import { Alert } from './alert.types';
|
||||
import { RequireCaps } from '../../common/auth/capabilities.decorator';
|
||||
|
||||
@Controller('patients/:patientId/alerts')
|
||||
export class AnalysisController {
|
||||
constructor(private readonly analysisService: AnalysisService) {}
|
||||
|
||||
@Get()
|
||||
@RequireCaps('alert:read')
|
||||
list(@Param('patientId') patientId: string): Promise<Alert[]> {
|
||||
return this.analysisService.listAlerts(patientId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Pool } from 'pg';
|
||||
import { AnalysisService } from './analysis.service';
|
||||
import { AnalysisController } from './analysis.controller';
|
||||
import { AlertRepository, InMemoryAlertRepository } from './alert.repository';
|
||||
import { PostgresAlertRepository } from './postgres-alert.repository';
|
||||
import { PG_POOL } from '../../common/db/db.tokens';
|
||||
import { createSealerFromEnv } from '../../common/crypto/field-sealer';
|
||||
|
||||
@Module({
|
||||
controllers: [AnalysisController],
|
||||
providers: [
|
||||
AnalysisService,
|
||||
{
|
||||
provide: AlertRepository,
|
||||
useFactory: (pool: Pool | null): AlertRepository =>
|
||||
pool ? new PostgresAlertRepository(pool, createSealerFromEnv()) : new InMemoryAlertRepository(),
|
||||
inject: [PG_POOL],
|
||||
},
|
||||
],
|
||||
exports: [AnalysisService],
|
||||
})
|
||||
export class AnalysisModule {}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { AlertRepository } from './alert.repository';
|
||||
import { Alert } from './alert.types';
|
||||
import { evaluateIndicator, RiskLevel } from './rule-engine';
|
||||
import { IndicatorType } from './indicator';
|
||||
|
||||
export interface AnalysisInput {
|
||||
patientId: string;
|
||||
observationId: string;
|
||||
indicator: IndicatorType;
|
||||
value: number;
|
||||
gestationalWeeks: number;
|
||||
}
|
||||
|
||||
export interface AnalysisResult {
|
||||
level: RiskLevel;
|
||||
alert: Alert | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析服务(REQ-3)。
|
||||
* 对单个观测值运行规则引擎;命中 medium/high 时生成可追溯预警。
|
||||
* 高风险的人工兜底与处置由 caseflow(T-5)/ai(T-7.3)负责,此处只产出预警。
|
||||
*/
|
||||
@Injectable()
|
||||
export class AnalysisService {
|
||||
constructor(private readonly alertRepo: AlertRepository) {}
|
||||
|
||||
async evaluateObservation(input: AnalysisInput): Promise<AnalysisResult> {
|
||||
const result = evaluateIndicator(input.indicator, input.value, {
|
||||
gestationalWeeks: input.gestationalWeeks,
|
||||
});
|
||||
|
||||
if (result.level === 'low' || result.hits.length === 0) {
|
||||
return { level: result.level, alert: null };
|
||||
}
|
||||
|
||||
const alert: Alert = {
|
||||
id: randomUUID(),
|
||||
patientId: input.patientId,
|
||||
observationId: input.observationId,
|
||||
indicator: input.indicator,
|
||||
value: input.value,
|
||||
level: result.level,
|
||||
ruleIds: result.hits.map((h) => h.ruleId),
|
||||
messages: result.hits.map((h) => h.message),
|
||||
status: 'open',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.alertRepo.save(alert);
|
||||
return { level: result.level, alert };
|
||||
}
|
||||
|
||||
listAlerts(patientId: string): Promise<Alert[]> {
|
||||
return this.alertRepo.findByPatient(patientId);
|
||||
}
|
||||
|
||||
listAllAlerts(): Promise<Alert[]> {
|
||||
return this.alertRepo.findAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 指标目录(REQ-1/REQ-3)。
|
||||
* 含单位与"生理合理范围"(用于数据质控 T-3.4)。
|
||||
* MVP 聚焦 GDM(血糖)与妊娠期高血压(血压)。
|
||||
*/
|
||||
|
||||
export type IndicatorType =
|
||||
| 'fasting_glucose' // 空腹血糖
|
||||
| 'ogtt_1h' // OGTT 1小时
|
||||
| 'ogtt_2h' // OGTT 2小时
|
||||
| 'postprandial_glucose' // 餐后血糖
|
||||
| 'systolic_bp' // 收缩压
|
||||
| 'diastolic_bp' // 舒张压
|
||||
| 'weight' // 体重
|
||||
| 'heart_rate'; // 心率
|
||||
|
||||
export interface IndicatorMeta {
|
||||
type: IndicatorType;
|
||||
label: string;
|
||||
unit: string;
|
||||
/** 生理合理范围(超出视为不可信,用于质控) */
|
||||
plausibleMin: number;
|
||||
plausibleMax: number;
|
||||
}
|
||||
|
||||
export const INDICATORS: Record<IndicatorType, IndicatorMeta> = {
|
||||
fasting_glucose: { type: 'fasting_glucose', label: '空腹血糖', unit: 'mmol/L', plausibleMin: 1, plausibleMax: 40 },
|
||||
ogtt_1h: { type: 'ogtt_1h', label: 'OGTT 1小时血糖', unit: 'mmol/L', plausibleMin: 1, plausibleMax: 40 },
|
||||
ogtt_2h: { type: 'ogtt_2h', label: 'OGTT 2小时血糖', unit: 'mmol/L', plausibleMin: 1, plausibleMax: 40 },
|
||||
postprandial_glucose: { type: 'postprandial_glucose', label: '餐后血糖', unit: 'mmol/L', plausibleMin: 1, plausibleMax: 40 },
|
||||
systolic_bp: { type: 'systolic_bp', label: '收缩压', unit: 'mmHg', plausibleMin: 50, plausibleMax: 300 },
|
||||
diastolic_bp: { type: 'diastolic_bp', label: '舒张压', unit: 'mmHg', plausibleMin: 30, plausibleMax: 200 },
|
||||
weight: { type: 'weight', label: '体重', unit: 'kg', plausibleMin: 30, plausibleMax: 200 },
|
||||
heart_rate: { type: 'heart_rate', label: '心率', unit: 'bpm', plausibleMin: 30, plausibleMax: 250 },
|
||||
};
|
||||
|
||||
export function isIndicatorType(value: string): value is IndicatorType {
|
||||
return Object.prototype.hasOwnProperty.call(INDICATORS, value);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Pool } from 'pg';
|
||||
import { AlertRepository } from './alert.repository';
|
||||
import { Alert } from './alert.types';
|
||||
import { IndicatorType } from './indicator';
|
||||
import { RiskLevel } from './rule-engine';
|
||||
import { FieldSealer } from '../../common/crypto/field-sealer';
|
||||
|
||||
interface SensitiveAlert {
|
||||
value: number;
|
||||
ruleIds: string[];
|
||||
messages: string[];
|
||||
}
|
||||
|
||||
interface AlertRow {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
observation_id: string | null;
|
||||
indicator: string;
|
||||
level: string;
|
||||
status: string;
|
||||
enc: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
/** PostgreSQL 预警仓储(T-1.2)。value/规则/说明加密入 enc。 */
|
||||
export class PostgresAlertRepository extends AlertRepository {
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly sealer: FieldSealer,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async save(alert: Alert): Promise<Alert> {
|
||||
const sensitive: SensitiveAlert = {
|
||||
value: alert.value,
|
||||
ruleIds: alert.ruleIds,
|
||||
messages: alert.messages,
|
||||
};
|
||||
await this.pool.query(
|
||||
`INSERT INTO alerts (id, patient_id, observation_id, indicator, level, status, enc, created_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (id) DO NOTHING`,
|
||||
[
|
||||
alert.id,
|
||||
alert.patientId,
|
||||
alert.observationId,
|
||||
alert.indicator,
|
||||
alert.level,
|
||||
alert.status,
|
||||
this.sealer.seal(sensitive),
|
||||
alert.createdAt,
|
||||
],
|
||||
);
|
||||
return alert;
|
||||
}
|
||||
|
||||
async findByPatient(patientId: string): Promise<Alert[]> {
|
||||
const res = await this.pool.query<AlertRow>(
|
||||
'SELECT * FROM alerts WHERE patient_id = $1 ORDER BY created_at',
|
||||
[patientId],
|
||||
);
|
||||
return res.rows.map((r) => this.toAlert(r));
|
||||
}
|
||||
|
||||
async findAll(): Promise<Alert[]> {
|
||||
const res = await this.pool.query<AlertRow>('SELECT * FROM alerts ORDER BY created_at DESC');
|
||||
return res.rows.map((r) => this.toAlert(r));
|
||||
}
|
||||
|
||||
private toAlert(row: AlertRow): Alert {
|
||||
const sensitive = this.sealer.open<SensitiveAlert>(row.enc);
|
||||
return {
|
||||
id: row.id,
|
||||
patientId: row.patient_id,
|
||||
observationId: row.observation_id ?? '',
|
||||
indicator: row.indicator as IndicatorType,
|
||||
value: sensitive.value,
|
||||
level: row.level as RiskLevel,
|
||||
ruleIds: sensitive.ruleIds,
|
||||
messages: sensitive.messages,
|
||||
status: row.status as Alert['status'],
|
||||
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { evaluateIndicator } from './rule-engine';
|
||||
|
||||
const ctx = { gestationalWeeks: 26 };
|
||||
|
||||
describe('rule-engine(规则引擎)', () => {
|
||||
it('正常空腹血糖 → low 无命中', () => {
|
||||
const r = evaluateIndicator('fasting_glucose', 4.8, ctx);
|
||||
expect(r.level).toBe('low');
|
||||
expect(r.hits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('空腹血糖达 GDM 界值 → medium', () => {
|
||||
const r = evaluateIndicator('fasting_glucose', 5.3, ctx);
|
||||
expect(r.level).toBe('medium');
|
||||
expect(r.hits[0].ruleId).toBe('FG-1');
|
||||
});
|
||||
|
||||
it('空腹血糖明显升高 → high(取最严重档)', () => {
|
||||
const r = evaluateIndicator('fasting_glucose', 7.5, ctx);
|
||||
expect(r.level).toBe('high');
|
||||
});
|
||||
|
||||
it('收缩压 140 → medium', () => {
|
||||
expect(evaluateIndicator('systolic_bp', 142, ctx).level).toBe('medium');
|
||||
});
|
||||
|
||||
it('收缩压 165 → high', () => {
|
||||
expect(evaluateIndicator('systolic_bp', 165, ctx).level).toBe('high');
|
||||
});
|
||||
|
||||
it('舒张压正常 → low', () => {
|
||||
expect(evaluateIndicator('diastolic_bp', 75, ctx).level).toBe('low');
|
||||
});
|
||||
|
||||
it('命中信息可解释(含 ruleId 与 message)', () => {
|
||||
const r = evaluateIndicator('diastolic_bp', 115, ctx);
|
||||
expect(r.hits[0].ruleId).toBe('DBP-1');
|
||||
expect(r.hits[0].message).toContain('舒张压');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 规则引擎(REQ-3.1/3.2/3.4)。
|
||||
* 单指标阈值规则 → 风险等级 + 可解释信息。按孕周适配(REQ-3 约束)。
|
||||
*
|
||||
* ⚠️ 阈值为通用临床常识占位(妊娠期糖尿病/高血压通行诊断界值),
|
||||
* 最终阈值与孕周分段须由专业医生确认(C-2 / A-1)。规则表可配置扩展(NFR-7)。
|
||||
*
|
||||
* 红旗急症组合规则属 T-4.3,不在本单指标引擎内。
|
||||
*/
|
||||
|
||||
export type RiskLevel = 'low' | 'medium' | 'high';
|
||||
export type IndicatorType = string;
|
||||
|
||||
export interface RuleContext {
|
||||
gestationalWeeks: number;
|
||||
}
|
||||
|
||||
export interface RuleHit {
|
||||
ruleId: string;
|
||||
level: RiskLevel;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ThresholdRule {
|
||||
id: string;
|
||||
indicator: IndicatorType;
|
||||
/** 适用条件(如孕周窗口);缺省表示始终适用 */
|
||||
appliesTo?: (ctx: RuleContext) => boolean;
|
||||
/** 评估单条规则,命中返回 RuleHit,否则 null */
|
||||
evaluate: (value: number, ctx: RuleContext) => RuleHit | null;
|
||||
}
|
||||
|
||||
/** 升序阈值辅助:从高到低匹配,命中最严重的一档 */
|
||||
function gradedRule(
|
||||
id: string,
|
||||
indicator: IndicatorType,
|
||||
grades: Array<{ min: number; level: RiskLevel; message: string }>,
|
||||
): ThresholdRule {
|
||||
const ordered = [...grades].sort((a, b) => b.min - a.min);
|
||||
return {
|
||||
id,
|
||||
indicator,
|
||||
evaluate(value): RuleHit | null {
|
||||
for (const g of ordered) {
|
||||
if (value >= g.min) {
|
||||
return { ruleId: id, level: g.level, message: g.message };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** MVP 规则表(GDM + 妊娠期高血压) */
|
||||
export const DEFAULT_RULES: readonly ThresholdRule[] = [
|
||||
gradedRule('FG-1', 'fasting_glucose', [
|
||||
{ min: 7.0, level: 'high', message: '空腹血糖明显升高(≥7.0 mmol/L),需尽快评估' },
|
||||
{ min: 5.1, level: 'medium', message: '空腹血糖达妊娠期糖尿病界值(≥5.1 mmol/L)' },
|
||||
]),
|
||||
gradedRule('OGTT1-1', 'ogtt_1h', [
|
||||
{ min: 10.0, level: 'medium', message: 'OGTT 1小时血糖达 GDM 界值(≥10.0 mmol/L)' },
|
||||
]),
|
||||
gradedRule('OGTT2-1', 'ogtt_2h', [
|
||||
{ min: 8.5, level: 'medium', message: 'OGTT 2小时血糖达 GDM 界值(≥8.5 mmol/L)' },
|
||||
]),
|
||||
gradedRule('SBP-1', 'systolic_bp', [
|
||||
{ min: 160, level: 'high', message: '收缩压重度升高(≥160 mmHg)' },
|
||||
{ min: 140, level: 'medium', message: '收缩压升高(≥140 mmHg),妊娠期高血压可能' },
|
||||
]),
|
||||
gradedRule('DBP-1', 'diastolic_bp', [
|
||||
{ min: 110, level: 'high', message: '舒张压重度升高(≥110 mmHg)' },
|
||||
{ min: 90, level: 'medium', message: '舒张压升高(≥90 mmHg),妊娠期高血压可能' },
|
||||
]),
|
||||
gradedRule('PPG-1', 'postprandial_glucose', [
|
||||
{ min: 11.1, level: 'high', message: '餐后血糖明显升高(≥11.1 mmol/L),需尽快评估' },
|
||||
{ min: 6.7, level: 'medium', message: '餐后2小时血糖达妊娠期糖耐量异常界值(≥6.7 mmol/L)' },
|
||||
]),
|
||||
{
|
||||
id: 'WT-1',
|
||||
indicator: 'weight',
|
||||
appliesTo: (ctx) => ctx.gestationalWeeks >= 28,
|
||||
evaluate: (val) => val >= 90 ? { ruleId: 'WT-1', level: 'medium', message: '孕晚期体重偏高(≥90.0 kg),需注意控制增重速度' } : null
|
||||
},
|
||||
{
|
||||
id: 'HR-1',
|
||||
indicator: 'heart_rate',
|
||||
evaluate: (val) => {
|
||||
if (val >= 110) {
|
||||
return { ruleId: 'HR-1', level: 'medium', message: '心率偏快(≥110 bpm),请静息复测' };
|
||||
}
|
||||
if (val <= 50) {
|
||||
return { ruleId: 'HR-1', level: 'medium', message: '心率偏慢(≤50 bpm),警惕低血压或房室阻滞可能' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const LEVEL_ORDER: Record<RiskLevel, number> = { low: 0, medium: 1, high: 2 };
|
||||
|
||||
export interface EvaluationResult {
|
||||
/** 综合风险等级(命中规则中的最高档;无命中为 low) */
|
||||
level: RiskLevel;
|
||||
hits: RuleHit[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估单个指标值。
|
||||
*/
|
||||
export function evaluateIndicator(
|
||||
indicator: IndicatorType,
|
||||
value: number,
|
||||
ctx: RuleContext,
|
||||
rules: readonly ThresholdRule[] = DEFAULT_RULES,
|
||||
): EvaluationResult {
|
||||
const hits: RuleHit[] = [];
|
||||
for (const rule of rules) {
|
||||
if (rule.indicator !== indicator) continue;
|
||||
if (rule.appliesTo && !rule.appliesTo(ctx)) continue;
|
||||
const hit = rule.evaluate(value, ctx);
|
||||
if (hit) hits.push(hit);
|
||||
}
|
||||
|
||||
const level = hits.reduce<RiskLevel>(
|
||||
(acc, h) => (LEVEL_ORDER[h.level] > LEVEL_ORDER[acc] ? h.level : acc),
|
||||
'low',
|
||||
);
|
||||
|
||||
return { level, hits };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { AuditController } from './audit.controller';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
describe('AuditController(审计查询 T-8.4)', () => {
|
||||
let service: AuditService;
|
||||
let controller: AuditController;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new AuditService();
|
||||
controller = new AuditController(service);
|
||||
service.record('u1', 'auth:login');
|
||||
service.record('u2', 'auth:register', 'patient');
|
||||
service.record('u1', 'case:advance', 'case-1');
|
||||
});
|
||||
|
||||
it('无过滤 → 返回全部,按时间倒序', async () => {
|
||||
const list = await controller.list();
|
||||
expect(list).toHaveLength(3);
|
||||
// 最新记录在前
|
||||
expect(list[0].action).toBe('case:advance');
|
||||
});
|
||||
|
||||
it('按操作者过滤', async () => {
|
||||
const list = await controller.list('u1');
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list.every((e) => e.actorId === 'u1')).toBe(true);
|
||||
});
|
||||
|
||||
it('按动作过滤', async () => {
|
||||
const list = await controller.list(undefined, 'auth:login');
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].action).toBe('auth:login');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { AuditEntry, AuditService } from './audit.service';
|
||||
import { RequireCaps } from '../../common/auth/capabilities.decorator';
|
||||
|
||||
/**
|
||||
* 审计查询(NFR-1/9 · T-1.3,运营/管理端 T-8.4)。
|
||||
* 支持按操作者/动作检索审计记录。
|
||||
*
|
||||
* 鉴权:受全局 JwtAuthGuard + CapabilitiesGuard 保护,需 `audit:read`(仅 admin)。
|
||||
*/
|
||||
@Controller('audit')
|
||||
export class AuditController {
|
||||
constructor(private readonly audit: AuditService) {}
|
||||
|
||||
@Get()
|
||||
@RequireCaps('audit:read')
|
||||
async list(
|
||||
@Query('actorId') actorId?: string,
|
||||
@Query('action') action?: string,
|
||||
): Promise<AuditEntry[]> {
|
||||
const entries = await this.audit.query({ actorId, action });
|
||||
return entries.slice().sort((a, b) => (a.at < b.at ? 1 : -1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { Pool } from 'pg';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuditController } from './audit.controller';
|
||||
import { InMemoryAuditRepository, PostgresAuditRepository } from './audit.repository';
|
||||
import { PG_POOL } from '../../common/db/db.tokens';
|
||||
|
||||
/**
|
||||
* 审计仓储按是否启用 PostgreSQL 自动切换(持久化 vs 内存)。
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [AuditController],
|
||||
providers: [
|
||||
{
|
||||
provide: AuditService,
|
||||
useFactory: (pool: Pool | null): AuditService =>
|
||||
new AuditService(pool ? new PostgresAuditRepository(pool) : new InMemoryAuditRepository()),
|
||||
inject: [PG_POOL],
|
||||
},
|
||||
],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Pool } from 'pg';
|
||||
import { AuditEntry, AuditFilter } from './audit.types';
|
||||
|
||||
/** 审计仓储抽象。内存(dev/测试)与 PostgreSQL(持久化)两种实现。 */
|
||||
export abstract class AuditRepository {
|
||||
abstract append(entry: AuditEntry): void | Promise<void>;
|
||||
abstract query(filter?: AuditFilter): Promise<AuditEntry[]>;
|
||||
}
|
||||
|
||||
export class InMemoryAuditRepository extends AuditRepository {
|
||||
private readonly entries: AuditEntry[] = [];
|
||||
|
||||
append(entry: AuditEntry): void {
|
||||
this.entries.push(entry);
|
||||
}
|
||||
|
||||
async query(filter?: AuditFilter): Promise<AuditEntry[]> {
|
||||
return this.entries.filter(
|
||||
(e) =>
|
||||
(!filter?.actorId || e.actorId === filter.actorId) &&
|
||||
(!filter?.action || e.action === filter.action),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface AuditRow {
|
||||
id: string;
|
||||
actor_id: string;
|
||||
action: string;
|
||||
target: string | null;
|
||||
at: Date;
|
||||
}
|
||||
|
||||
/** PostgreSQL 审计仓储(T-1.3/NFR-9 持久化、防丢失)。 */
|
||||
export class PostgresAuditRepository extends AuditRepository {
|
||||
constructor(private readonly pool: Pool) {
|
||||
super();
|
||||
}
|
||||
|
||||
async append(entry: AuditEntry): Promise<void> {
|
||||
await this.pool.query(
|
||||
`INSERT INTO audit_log (id, actor_id, action, target, at) VALUES ($1, $2, $3, $4, $5)`,
|
||||
[entry.id, entry.actorId, entry.action, entry.target ?? null, entry.at],
|
||||
);
|
||||
}
|
||||
|
||||
async query(filter?: AuditFilter): Promise<AuditEntry[]> {
|
||||
const conds: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (filter?.actorId) {
|
||||
params.push(filter.actorId);
|
||||
conds.push(`actor_id = $${params.length}`);
|
||||
}
|
||||
if (filter?.action) {
|
||||
params.push(filter.action);
|
||||
conds.push(`action = $${params.length}`);
|
||||
}
|
||||
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
|
||||
const res = await this.pool.query<AuditRow>(`SELECT * FROM audit_log ${where}`, params);
|
||||
return res.rows.map(toEntry);
|
||||
}
|
||||
}
|
||||
|
||||
function toEntry(row: AuditRow): AuditEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
actorId: row.actor_id,
|
||||
action: row.action,
|
||||
target: row.target ?? undefined,
|
||||
at: row.at instanceof Date ? row.at.toISOString() : String(row.at),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { AuditEntry, AuditFilter } from './audit.types';
|
||||
import { AuditRepository, InMemoryAuditRepository } from './audit.repository';
|
||||
|
||||
// 向后兼容:保留从本模块导出 AuditEntry
|
||||
export type { AuditEntry } from './audit.types';
|
||||
|
||||
/**
|
||||
* 操作审计(T-1.3 / NFR-1/9)。
|
||||
* 关键操作(数据访问、处置、配置、越权)留痕,支持合规与责任界定。
|
||||
* 仓储可切换:未配置 DB 时为内存;配置 DATABASE_URL 时为 PostgreSQL(持久化、防丢失)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
private readonly logger = new Logger('Audit');
|
||||
private readonly repo: AuditRepository;
|
||||
|
||||
constructor(repo?: AuditRepository) {
|
||||
this.repo = repo ?? new InMemoryAuditRepository();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录审计。返回构造的条目(同步);实际写入为 fire-and-forget,
|
||||
* 写入失败仅记录日志,不阻塞主流程(审计不应影响业务可用性)。
|
||||
*/
|
||||
record(actorId: string, action: string, target?: string): AuditEntry {
|
||||
const entry: AuditEntry = {
|
||||
id: randomUUID(),
|
||||
actorId,
|
||||
action,
|
||||
target,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
void Promise.resolve(this.repo.append(entry)).catch((e) =>
|
||||
this.logger.error(`审计写入失败:${String(e)}`),
|
||||
);
|
||||
this.logger.log(`${actorId} ${action}${target ? ' ' + target : ''}`);
|
||||
return entry;
|
||||
}
|
||||
|
||||
query(filter?: AuditFilter): Promise<AuditEntry[]> {
|
||||
return this.repo.query(filter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
actorId: string;
|
||||
action: string;
|
||||
target?: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface AuditFilter {
|
||||
actorId?: string;
|
||||
action?: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { AuthService, RegisterInput } from './auth.service';
|
||||
import { AuthResult } from './auth.types';
|
||||
import { Public } from '../../common/auth/public.decorator';
|
||||
|
||||
@Public()
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
register(@Body() body: RegisterInput): Promise<AuthResult> {
|
||||
return this.auth.register(body);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() body: { username: string; password: string }): Promise<AuthResult> {
|
||||
return this.auth.login(body.username, body.password);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Pool } from 'pg';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthRepository, InMemoryAuthRepository } from './auth.repository';
|
||||
import { PostgresAuthRepository } from './postgres-auth.repository';
|
||||
import { PG_POOL } from '../../common/db/db.tokens';
|
||||
|
||||
/**
|
||||
* 仓储按是否启用 PostgreSQL 自动切换:
|
||||
* - PG_POOL 存在 → PostgresAuthRepository(持久化);
|
||||
* - 否则 → InMemoryAuthRepository(dev/测试)。
|
||||
*/
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
{
|
||||
provide: AuthRepository,
|
||||
useFactory: (pool: Pool | null): AuthRepository =>
|
||||
pool ? new PostgresAuthRepository(pool) : new InMemoryAuthRepository(),
|
||||
inject: [PG_POOL],
|
||||
},
|
||||
],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { User } from './auth.types';
|
||||
import { hashPassword } from './password';
|
||||
|
||||
export abstract class AuthRepository {
|
||||
abstract save(user: User): Promise<User>;
|
||||
abstract findByUsername(username: string): Promise<User | null>;
|
||||
abstract findById(id: string): Promise<User | null>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InMemoryAuthRepository extends AuthRepository {
|
||||
private readonly users = new Map<string, User>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.seedDemoUsers();
|
||||
}
|
||||
|
||||
private seedDemoUsers() {
|
||||
const demoUsers: User[] = [
|
||||
{
|
||||
id: 'demo-pregnant-01',
|
||||
username: 'test_pregnant_01',
|
||||
passwordHash: hashPassword('12345678'),
|
||||
role: 'patient',
|
||||
consentSigned: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'demo-pregnant-02',
|
||||
username: 'test_pregnant_02',
|
||||
passwordHash: hashPassword('12345678'),
|
||||
role: 'patient',
|
||||
consentSigned: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'demo-family-01',
|
||||
username: 'test_family_01',
|
||||
passwordHash: hashPassword('12345678'),
|
||||
role: 'family',
|
||||
consentSigned: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'demo-manager-01',
|
||||
username: 'test_manager_01',
|
||||
passwordHash: hashPassword('12345678'),
|
||||
role: 'case_manager',
|
||||
consentSigned: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'demo-doctor-01',
|
||||
username: 'test_doctor_01',
|
||||
passwordHash: hashPassword('12345678'),
|
||||
role: 'physician',
|
||||
consentSigned: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'demo-operator-01',
|
||||
username: 'test_operator_01',
|
||||
passwordHash: hashPassword('12345678'),
|
||||
role: 'operator',
|
||||
consentSigned: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'demo-admin-01',
|
||||
username: 'test_admin_01',
|
||||
passwordHash: hashPassword('12345678'),
|
||||
role: 'admin',
|
||||
consentSigned: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
for (const user of demoUsers) {
|
||||
this.users.set(user.id, user);
|
||||
}
|
||||
}
|
||||
|
||||
async save(user: User): Promise<User> {
|
||||
this.users.set(user.id, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<User | null> {
|
||||
for (const u of this.users.values()) {
|
||||
if (u.username === username) return u;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
return this.users.get(id) ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { BadRequestException, ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { InMemoryAuthRepository } from './auth.repository';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { verifyToken } from './token';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new AuthService(new InMemoryAuthRepository(), new AuditService());
|
||||
});
|
||||
|
||||
it('医护注册并签发可验证令牌', async () => {
|
||||
const res = await service.register({ username: 'cm1', password: 'secret123', role: 'case_manager' });
|
||||
expect(res.user.role).toBe('case_manager');
|
||||
const payload = verifyToken(res.token, process.env.AUTH_SECRET ?? 'dev-secret-change-me');
|
||||
expect(payload?.sub).toBe(res.user.id);
|
||||
});
|
||||
|
||||
it('孕妇未签知情同意 → 注册被拒(T-1.1)', async () => {
|
||||
await expect(
|
||||
service.register({ username: 'mom1', password: 'secret123', role: 'patient' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('孕妇签署知情同意后可注册', async () => {
|
||||
const res = await service.register({
|
||||
username: 'mom1',
|
||||
password: 'secret123',
|
||||
role: 'patient',
|
||||
consent: true,
|
||||
});
|
||||
expect(res.user.consentSigned).toBe(true);
|
||||
});
|
||||
|
||||
it('用户名重复 → 冲突', async () => {
|
||||
await service.register({ username: 'cm1', password: 'secret123', role: 'case_manager' });
|
||||
await expect(
|
||||
service.register({ username: 'cm1', password: 'secret123', role: 'case_manager' }),
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('登录成功返回令牌', async () => {
|
||||
await service.register({ username: 'cm1', password: 'secret123', role: 'case_manager' });
|
||||
const res = await service.login('cm1', 'secret123');
|
||||
expect(res.token).toBeDefined();
|
||||
});
|
||||
|
||||
it('密码错误 → 未授权', async () => {
|
||||
await service.register({ username: 'cm1', password: 'secret123', role: 'case_manager' });
|
||||
await expect(service.login('cm1', 'wrongpass')).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { AuthRepository } from './auth.repository';
|
||||
import { AuthResult, User } from './auth.types';
|
||||
import { hashPassword, verifyPassword } from './password';
|
||||
import { signToken } from './token';
|
||||
import { Role } from './rbac';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { getAuthSecret } from '../../common/auth/auth-secret';
|
||||
|
||||
export interface RegisterInput {
|
||||
username: string;
|
||||
password: string;
|
||||
role: Role;
|
||||
/** 注册时是否签署知情同意(T-1.1,孕妇/家属端必须) */
|
||||
consent?: boolean;
|
||||
}
|
||||
|
||||
const TOKEN_TTL_SECONDS = 60 * 60 * 8; // 8h
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly secret: string;
|
||||
|
||||
constructor(
|
||||
private readonly repo: AuthRepository,
|
||||
private readonly audit: AuditService,
|
||||
) {
|
||||
this.secret = getAuthSecret();
|
||||
}
|
||||
|
||||
async register(input: RegisterInput): Promise<AuthResult> {
|
||||
if (!input.username?.trim()) {
|
||||
throw new BadRequestException('用户名不能为空');
|
||||
}
|
||||
const exists = await this.repo.findByUsername(input.username);
|
||||
if (exists) {
|
||||
throw new ConflictException('用户名已存在');
|
||||
}
|
||||
// 孕妇/家属须先签署知情同意(T-1.1)
|
||||
if ((input.role === 'patient' || input.role === 'family') && !input.consent) {
|
||||
throw new BadRequestException('需先签署知情同意方可注册');
|
||||
}
|
||||
|
||||
const user: User = {
|
||||
id: randomUUID(),
|
||||
username: input.username.trim(),
|
||||
passwordHash: hashPassword(input.password),
|
||||
role: input.role,
|
||||
consentSigned: Boolean(input.consent),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await this.repo.save(user);
|
||||
this.audit.record(user.id, 'auth:register', user.role);
|
||||
return this.issue(user);
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<AuthResult> {
|
||||
const user = await this.repo.findByUsername(username);
|
||||
if (!user || !verifyPassword(password, user.passwordHash)) {
|
||||
throw new UnauthorizedException('用户名或密码错误');
|
||||
}
|
||||
this.audit.record(user.id, 'auth:login');
|
||||
return this.issue(user);
|
||||
}
|
||||
|
||||
private issue(user: User): AuthResult {
|
||||
const token = signToken(
|
||||
{ sub: user.id, role: user.role, exp: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS },
|
||||
this.secret,
|
||||
);
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
consentSigned: user.consentSigned,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Role } from './rbac';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
role: Role;
|
||||
/** 是否已签署知情同意(T-1.1) */
|
||||
consentSigned: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
token: string;
|
||||
user: { id: string; username: string; role: Role; consentSigned: boolean };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { hashPassword, verifyPassword } from './password';
|
||||
|
||||
describe('password(密码哈希)', () => {
|
||||
it('哈希后可验证通过', () => {
|
||||
const stored = hashPassword('secret123');
|
||||
expect(verifyPassword('secret123', stored)).toBe(true);
|
||||
});
|
||||
|
||||
it('错误密码验证失败', () => {
|
||||
const stored = hashPassword('secret123');
|
||||
expect(verifyPassword('wrongpass', stored)).toBe(false);
|
||||
});
|
||||
|
||||
it('过短密码抛错', () => {
|
||||
expect(() => hashPassword('123')).toThrow();
|
||||
});
|
||||
|
||||
it('相同密码两次哈希不同(含随机盐)', () => {
|
||||
expect(hashPassword('secret123')).not.toBe(hashPassword('secret123'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 密码哈希(T-1.2 部分 / NFR-1)。
|
||||
* 使用 Node 内置 scrypt + 随机盐;存储 salt:hash。
|
||||
* 注:生产建议采用经审计的库(如 argon2/bcrypt)并配置合适代价参数。
|
||||
*/
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
const KEYLEN = 64;
|
||||
|
||||
export function hashPassword(plain: string): string {
|
||||
if (!plain || plain.length < 8) {
|
||||
throw new Error('密码至少 8 位');
|
||||
}
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const hash = scryptSync(plain, salt, KEYLEN).toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
export function verifyPassword(plain: string, stored: string): boolean {
|
||||
const [salt, hash] = stored.split(':');
|
||||
if (!salt || !hash) return false;
|
||||
const candidate = scryptSync(plain, salt, KEYLEN);
|
||||
const expected = Buffer.from(hash, 'hex');
|
||||
if (candidate.length !== expected.length) return false;
|
||||
return timingSafeEqual(candidate, expected);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Pool } from 'pg';
|
||||
import { AuthRepository } from './auth.repository';
|
||||
import { User } from './auth.types';
|
||||
import { Role } from './rbac';
|
||||
import { hashPassword } from './password';
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
role: string;
|
||||
consent_signed: boolean;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
/** PostgreSQL 用户仓储(T-1.2 持久化)。 */
|
||||
export class PostgresAuthRepository extends AuthRepository {
|
||||
constructor(private readonly pool: Pool) {
|
||||
super();
|
||||
this.seedDemoUsers();
|
||||
}
|
||||
|
||||
private async seedDemoUsers() {
|
||||
try {
|
||||
const demoUsers = [
|
||||
{
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
username: 'test_pregnant_01',
|
||||
passwordHash: hashPassword('12345678'),
|
||||
role: 'patient',
|
||||
consentSigned: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-0000-0000-000000000002',
|
||||
username: 'test_pregnant_02',
|
||||
passwordHash: hashPassword('12345678'),
|
||||
role: 'patient',
|
||||
consentSigned: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-0000-0000-000000000003',
|
||||
username: 'test_family_01',
|
||||
passwordHash: hashPassword('12345678'),
|
||||
role: 'family',
|
||||
consentSigned: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-0000-0000-000000000004',
|
||||
username: 'test_manager_01',
|
||||
passwordHash: hashPassword('12345678'),
|
||||
role: 'case_manager',
|
||||
consentSigned: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-0000-0000-000000000005',
|
||||
username: 'test_doctor_01',
|
||||
passwordHash: hashPassword('12345678'),
|
||||
role: 'physician',
|
||||
consentSigned: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-0000-0000-000000000006',
|
||||
username: 'test_operator_01',
|
||||
passwordHash: hashPassword('12345678'),
|
||||
role: 'operator',
|
||||
consentSigned: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-0000-0000-000000000007',
|
||||
username: 'test_admin_01',
|
||||
passwordHash: hashPassword('12345678'),
|
||||
role: 'admin',
|
||||
consentSigned: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
for (const u of demoUsers) {
|
||||
await this.pool.query(
|
||||
`INSERT INTO users (id, username, password_hash, role, consent_signed, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (username) DO NOTHING`,
|
||||
[u.id, u.username, u.passwordHash, u.role, u.consentSigned, u.createdAt],
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Database] Failed to seed postgres demo users:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async save(user: User): Promise<User> {
|
||||
await this.pool.query(
|
||||
`INSERT INTO users (id, username, password_hash, role, consent_signed, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[user.id, user.username, user.passwordHash, user.role, user.consentSigned, user.createdAt],
|
||||
);
|
||||
return user;
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<User | null> {
|
||||
const res = await this.pool.query<UserRow>('SELECT * FROM users WHERE username = $1', [username]);
|
||||
return res.rows[0] ? toUser(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
const res = await this.pool.query<UserRow>('SELECT * FROM users WHERE id = $1', [id]);
|
||||
return res.rows[0] ? toUser(res.rows[0]) : null;
|
||||
}
|
||||
}
|
||||
|
||||
function toUser(row: UserRow): User {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash,
|
||||
role: row.role as Role,
|
||||
consentSigned: row.consent_signed,
|
||||
createdAt:
|
||||
row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { can } from './rbac';
|
||||
|
||||
describe('rbac(角色权限)', () => {
|
||||
it('孕妇可录入与问答,不可制定计划', () => {
|
||||
expect(can('patient', 'observation:record')).toBe(true);
|
||||
expect(can('patient', 'knowledge:ask')).toBe(true);
|
||||
expect(can('patient', 'careplan:write')).toBe(false);
|
||||
});
|
||||
|
||||
it('个案管理师可制定计划与推进个案', () => {
|
||||
expect(can('case_manager', 'careplan:write')).toBe(true);
|
||||
expect(can('case_manager', 'case:advance')).toBe(true);
|
||||
});
|
||||
|
||||
it('医生可写计划但不做系统配置', () => {
|
||||
expect(can('physician', 'careplan:write')).toBe(true);
|
||||
expect(can('physician', 'admin:config')).toBe(false);
|
||||
});
|
||||
|
||||
it('管理员可配置与查审计', () => {
|
||||
expect(can('admin', 'admin:config')).toBe(true);
|
||||
expect(can('admin', 'audit:read')).toBe(true);
|
||||
});
|
||||
|
||||
it('家属权限受限', () => {
|
||||
expect(can('family', 'patient:read')).toBe(true);
|
||||
expect(can('family', 'observation:record')).toBe(false);
|
||||
});
|
||||
|
||||
it('孕妇可自助建档、自设提醒、查看本人预警(自助服务)', () => {
|
||||
expect(can('patient', 'patient:create')).toBe(true);
|
||||
expect(can('patient', 'reminder:dispatch')).toBe(true);
|
||||
expect(can('patient', 'alert:read')).toBe(true);
|
||||
});
|
||||
|
||||
it('家属不可自助建档/设提醒/查预警', () => {
|
||||
expect(can('family', 'patient:create')).toBe(false);
|
||||
expect(can('family', 'reminder:dispatch')).toBe(false);
|
||||
expect(can('family', 'alert:read')).toBe(false);
|
||||
});
|
||||
|
||||
it('运营可维护知识并测试问答', () => {
|
||||
expect(can('operator', 'knowledge:write')).toBe(true);
|
||||
expect(can('operator', 'knowledge:ask')).toBe(true);
|
||||
expect(can('operator', 'audit:read')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* RBAC 权限(REQ-11.1 / PRD §6)。纯逻辑,便于测试。
|
||||
* 仅做能力级(action)授权;记录级(仅限本人/负责个案)由各服务结合上下文校验。
|
||||
*/
|
||||
export type Role =
|
||||
| 'patient'
|
||||
| 'family'
|
||||
| 'case_manager'
|
||||
| 'physician'
|
||||
| 'operator'
|
||||
| 'admin';
|
||||
|
||||
export type Action =
|
||||
| 'patient:create'
|
||||
| 'patient:read'
|
||||
| 'observation:record'
|
||||
| 'observation:read'
|
||||
| 'alert:read'
|
||||
| 'careplan:write'
|
||||
| 'case:advance'
|
||||
| 'redflag:check'
|
||||
| 'knowledge:write'
|
||||
| 'knowledge:ask'
|
||||
| 'reminder:dispatch'
|
||||
| 'disposition:create'
|
||||
| 'disposition:read'
|
||||
| 'disposition:confirm'
|
||||
| 'disposition:execute'
|
||||
| 'admin:config'
|
||||
| 'audit:read'
|
||||
| 'referral:create'
|
||||
| 'referral:respond'
|
||||
| 'emotion:create'
|
||||
| 'emotion:read';
|
||||
|
||||
const MATRIX: Record<Role, Action[]> = {
|
||||
// 孕妇自助:自助建档、录入/查看本人数据、查看本人预警、自设提醒、问答、红旗自查、情绪打卡
|
||||
patient: [
|
||||
'patient:create',
|
||||
'patient:read',
|
||||
'observation:record',
|
||||
'observation:read',
|
||||
'alert:read',
|
||||
'reminder:dispatch',
|
||||
'knowledge:ask',
|
||||
'redflag:check',
|
||||
'emotion:create',
|
||||
],
|
||||
family: ['patient:read', 'knowledge:ask'],
|
||||
case_manager: [
|
||||
'patient:create',
|
||||
'patient:read',
|
||||
'observation:record',
|
||||
'observation:read',
|
||||
'alert:read',
|
||||
'careplan:write',
|
||||
'case:advance',
|
||||
'redflag:check',
|
||||
'knowledge:ask',
|
||||
'knowledge:write',
|
||||
'reminder:dispatch',
|
||||
'disposition:create',
|
||||
'disposition:read',
|
||||
'disposition:confirm',
|
||||
'disposition:execute',
|
||||
'referral:create',
|
||||
'emotion:read',
|
||||
],
|
||||
physician: [
|
||||
'patient:read',
|
||||
'observation:read',
|
||||
'alert:read',
|
||||
'careplan:write',
|
||||
'case:advance',
|
||||
'knowledge:write',
|
||||
'knowledge:ask',
|
||||
'disposition:create',
|
||||
'disposition:read',
|
||||
'disposition:confirm',
|
||||
'disposition:execute',
|
||||
'referral:create',
|
||||
'referral:respond',
|
||||
'emotion:read',
|
||||
],
|
||||
operator: ['patient:read', 'knowledge:write', 'knowledge:ask', 'admin:config'],
|
||||
admin: [
|
||||
'patient:read',
|
||||
'admin:config',
|
||||
'audit:read',
|
||||
'knowledge:write',
|
||||
'knowledge:ask',
|
||||
'disposition:read',
|
||||
'referral:respond',
|
||||
'emotion:read',
|
||||
],
|
||||
};
|
||||
|
||||
export function can(role: Role, action: Action): boolean {
|
||||
return MATRIX[role]?.includes(action) ?? false;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { signToken, verifyToken } from './token';
|
||||
|
||||
const secret = 'test-secret';
|
||||
|
||||
describe('token(签名令牌)', () => {
|
||||
it('签发的令牌可验证', () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600;
|
||||
const token = signToken({ sub: 'u1', role: 'patient', exp }, secret);
|
||||
const payload = verifyToken(token, secret);
|
||||
expect(payload?.sub).toBe('u1');
|
||||
expect(payload?.role).toBe('patient');
|
||||
});
|
||||
|
||||
it('错误密钥验证失败', () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600;
|
||||
const token = signToken({ sub: 'u1', role: 'patient', exp }, secret);
|
||||
expect(verifyToken(token, 'other-secret')).toBeNull();
|
||||
});
|
||||
|
||||
it('过期令牌验证失败', () => {
|
||||
const exp = Math.floor(Date.now() / 1000) - 10;
|
||||
const token = signToken({ sub: 'u1', role: 'patient', exp }, secret);
|
||||
expect(verifyToken(token, secret)).toBeNull();
|
||||
});
|
||||
|
||||
it('被篡改令牌验证失败', () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600;
|
||||
const token = signToken({ sub: 'u1', role: 'patient', exp }, secret);
|
||||
expect(verifyToken(token + 'x', secret)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 简单签名令牌(HMAC)。MVP 用途。
|
||||
* 注:生产建议使用标准 JWT 库与密钥轮换;此处仅提供可验证的最小实现。
|
||||
*/
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export interface TokenPayload {
|
||||
sub: string; // userId
|
||||
role: string;
|
||||
exp: number; // epoch seconds
|
||||
}
|
||||
|
||||
function b64url(input: string): string {
|
||||
return Buffer.from(input).toString('base64url');
|
||||
}
|
||||
|
||||
function sign(data: string, secret: string): string {
|
||||
return createHmac('sha256', secret).update(data).digest('base64url');
|
||||
}
|
||||
|
||||
export function signToken(payload: TokenPayload, secret: string): string {
|
||||
const body = b64url(JSON.stringify(payload));
|
||||
const sig = sign(body, secret);
|
||||
return `${body}.${sig}`;
|
||||
}
|
||||
|
||||
export function verifyToken(token: string, secret: string): TokenPayload | null {
|
||||
const [body, sig] = token.split('.');
|
||||
if (!body || !sig) return null;
|
||||
|
||||
const expected = sign(body, secret);
|
||||
const a = Buffer.from(sig);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(body, 'base64url').toString()) as TokenPayload;
|
||||
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type InterventionKind =
|
||||
| 'clinical' // 临床(用药提醒、转诊)
|
||||
| 'lifestyle' // 生活方式(饮食、运动、休息、喝水)
|
||||
| 'habit'; // 习惯养成/游戏化
|
||||
|
||||
export interface Intervention {
|
||||
kind: InterventionKind;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type CarePlanStatus = 'active' | 'archived';
|
||||
|
||||
/** 照护计划(REQ-6.3) */
|
||||
export interface CarePlan {
|
||||
id: string;
|
||||
caseId: string;
|
||||
patientId: string;
|
||||
goals: string[];
|
||||
interventions: Intervention[];
|
||||
/** 随访频率(如 "weekly" / "biweekly" / 自定义说明) */
|
||||
followUpFrequency: string;
|
||||
status: CarePlanStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { canTransition, nextStages, stageOnAlert } from './case-state-machine';
|
||||
|
||||
describe('case-state-machine(个案状态机)', () => {
|
||||
it('允许的正向流转', () => {
|
||||
expect(canTransition('screening', 'assessment')).toBe(true);
|
||||
expect(canTransition('planning', 'implementation')).toBe(true);
|
||||
expect(canTransition('evaluation', 'transition')).toBe(true);
|
||||
});
|
||||
|
||||
it('不允许的跳跃流转', () => {
|
||||
expect(canTransition('screening', 'planning')).toBe(false);
|
||||
expect(canTransition('transition', 'assessment')).toBe(false);
|
||||
});
|
||||
|
||||
it('监测/评价可回到评估(闭环回路)', () => {
|
||||
expect(canTransition('monitoring', 'assessment')).toBe(true);
|
||||
expect(canTransition('evaluation', 'assessment')).toBe(true);
|
||||
});
|
||||
|
||||
it('nextStages 返回可达阶段', () => {
|
||||
expect(nextStages('monitoring')).toEqual(expect.arrayContaining(['evaluation', 'assessment']));
|
||||
expect(nextStages('transition')).toEqual([]);
|
||||
});
|
||||
|
||||
it('低风险预警不改变阶段', () => {
|
||||
expect(stageOnAlert('monitoring', 'low')).toBe('monitoring');
|
||||
});
|
||||
|
||||
it('监测中的中/高风险预警 → 回到评估', () => {
|
||||
expect(stageOnAlert('monitoring', 'medium')).toBe('assessment');
|
||||
expect(stageOnAlert('evaluation', 'high')).toBe('assessment');
|
||||
});
|
||||
|
||||
it('其他阶段的预警保持当前阶段', () => {
|
||||
expect(stageOnAlert('planning', 'high')).toBe('planning');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 个案管理状态机(REQ-6.1)。纯逻辑,便于测试。
|
||||
* 标准闭环:筛查→评估→分层→计划→实施→监测→评价→(转出 | 回到评估)
|
||||
*/
|
||||
import { CaseStage, RiskLevel } from './case.types';
|
||||
|
||||
/** 各阶段允许流转到的下一阶段 */
|
||||
const TRANSITIONS: Record<CaseStage, CaseStage[]> = {
|
||||
screening: ['assessment'],
|
||||
assessment: ['risk_stratification'],
|
||||
risk_stratification: ['planning'],
|
||||
planning: ['implementation'],
|
||||
implementation: ['monitoring'],
|
||||
monitoring: ['evaluation', 'assessment'], // 监测中发现新风险可回到评估
|
||||
evaluation: ['transition', 'assessment'], // 达标转出,否则回到评估
|
||||
transition: [],
|
||||
};
|
||||
|
||||
export function canTransition(from: CaseStage, to: CaseStage): boolean {
|
||||
return TRANSITIONS[from].includes(to);
|
||||
}
|
||||
|
||||
export function nextStages(from: CaseStage): CaseStage[] {
|
||||
return [...TRANSITIONS[from]];
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到预警时建议的阶段流转:
|
||||
* - 中/高风险且处于监测/评价阶段 → 回到评估(触发重新评估与处置)
|
||||
* - 其他情况保持当前阶段
|
||||
*/
|
||||
export function stageOnAlert(current: CaseStage, level: RiskLevel): CaseStage {
|
||||
if (level === 'low') return current;
|
||||
if (current === 'monitoring' || current === 'evaluation') {
|
||||
return 'assessment';
|
||||
}
|
||||
return current;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export type RiskLevel = 'low' | 'medium' | 'high';
|
||||
|
||||
/** 个案管理流程阶段(REQ-6.1,对应基础构想个案管理流程) */
|
||||
export type CaseStage =
|
||||
| 'screening' // 筛查
|
||||
| 'assessment' // 评估
|
||||
| 'risk_stratification' // 风险分层
|
||||
| 'planning' // 计划制定
|
||||
| 'implementation' // 实施/协调
|
||||
| 'monitoring' // 监测随访
|
||||
| 'evaluation' // 评价
|
||||
| 'transition'; // 转出
|
||||
|
||||
export type CaseStatus = 'open' | 'closed';
|
||||
|
||||
export interface CaseEvent {
|
||||
at: string;
|
||||
from: CaseStage;
|
||||
to: CaseStage;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** 个案(REQ-6):一名孕妇的管理实例 */
|
||||
export interface PregnancyCase {
|
||||
id: string;
|
||||
patientId: string;
|
||||
caseManagerId: string | null;
|
||||
stage: CaseStage;
|
||||
status: CaseStatus;
|
||||
/** 当前风险等级(随预警更新) */
|
||||
riskLevel: RiskLevel;
|
||||
history: CaseEvent[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { CaseflowService, CreateCarePlanInput } from './caseflow.service';
|
||||
import { CaseStage, PregnancyCase, RiskLevel } from './case.types';
|
||||
import { CarePlan } from './care-plan.types';
|
||||
import { RequireCaps } from '../../common/auth/capabilities.decorator';
|
||||
|
||||
@Controller()
|
||||
export class CaseflowController {
|
||||
constructor(private readonly caseflow: CaseflowService) {}
|
||||
|
||||
/** 开案 */
|
||||
@Post('patients/:patientId/case')
|
||||
@RequireCaps('case:advance')
|
||||
open(
|
||||
@Param('patientId') patientId: string,
|
||||
@Body() body: { riskLevel?: RiskLevel },
|
||||
): Promise<PregnancyCase> {
|
||||
return this.caseflow.openCase(patientId, body.riskLevel ?? 'low');
|
||||
}
|
||||
|
||||
@Get('patients/:patientId/case')
|
||||
@RequireCaps('patient:read')
|
||||
get(@Param('patientId') patientId: string): Promise<PregnancyCase> {
|
||||
return this.caseflow.getCaseByPatient(patientId);
|
||||
}
|
||||
|
||||
/** 推进阶段 */
|
||||
@Post('cases/:caseId/advance')
|
||||
@RequireCaps('case:advance')
|
||||
advance(
|
||||
@Param('caseId') caseId: string,
|
||||
@Body() body: { to: CaseStage; reason?: string },
|
||||
): Promise<PregnancyCase> {
|
||||
return this.caseflow.advanceStage(caseId, body.to, body.reason ?? '手动推进');
|
||||
}
|
||||
|
||||
/** 指派管理师 */
|
||||
@Post('cases/:caseId/assign')
|
||||
@RequireCaps('case:advance')
|
||||
assign(
|
||||
@Param('caseId') caseId: string,
|
||||
@Body() body: { caseManagerId: string },
|
||||
): Promise<PregnancyCase> {
|
||||
return this.caseflow.assignManager(caseId, body.caseManagerId);
|
||||
}
|
||||
|
||||
/** 制定照护计划 */
|
||||
@Post('cases/:caseId/care-plans')
|
||||
@RequireCaps('careplan:write')
|
||||
createPlan(
|
||||
@Param('caseId') caseId: string,
|
||||
@Body() body: CreateCarePlanInput,
|
||||
): Promise<CarePlan> {
|
||||
return this.caseflow.createCarePlan(caseId, body);
|
||||
}
|
||||
|
||||
@Get('cases/:caseId/care-plans')
|
||||
@RequireCaps('patient:read')
|
||||
listPlans(@Param('caseId') caseId: string): Promise<CarePlan[]> {
|
||||
return this.caseflow.listCarePlans(caseId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Pool } from 'pg';
|
||||
import { CaseflowService } from './caseflow.service';
|
||||
import { CaseflowController } from './caseflow.controller';
|
||||
import { CaseflowRepository, InMemoryCaseflowRepository } from './caseflow.repository';
|
||||
import { PostgresCaseflowRepository } from './postgres-caseflow.repository';
|
||||
import { PG_POOL } from '../../common/db/db.tokens';
|
||||
import { createSealerFromEnv } from '../../common/crypto/field-sealer';
|
||||
|
||||
@Module({
|
||||
controllers: [CaseflowController],
|
||||
providers: [
|
||||
CaseflowService,
|
||||
{
|
||||
provide: CaseflowRepository,
|
||||
useFactory: (pool: Pool | null): CaseflowRepository =>
|
||||
pool
|
||||
? new PostgresCaseflowRepository(pool, createSealerFromEnv())
|
||||
: new InMemoryCaseflowRepository(),
|
||||
inject: [PG_POOL],
|
||||
},
|
||||
],
|
||||
exports: [CaseflowService],
|
||||
})
|
||||
export class CaseflowModule {}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PregnancyCase } from './case.types';
|
||||
import { CarePlan } from './care-plan.types';
|
||||
|
||||
/** 个案与照护计划仓储抽象(内存实现,后续接入 DB)。 */
|
||||
export abstract class CaseflowRepository {
|
||||
abstract saveCase(c: PregnancyCase): Promise<PregnancyCase>;
|
||||
abstract findCaseById(id: string): Promise<PregnancyCase | null>;
|
||||
abstract findCaseByPatient(patientId: string): Promise<PregnancyCase | null>;
|
||||
abstract savePlan(p: CarePlan): Promise<CarePlan>;
|
||||
abstract findPlansByCase(caseId: string): Promise<CarePlan[]>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InMemoryCaseflowRepository extends CaseflowRepository {
|
||||
private readonly cases = new Map<string, PregnancyCase>();
|
||||
private readonly plans: CarePlan[] = [];
|
||||
|
||||
async saveCase(c: PregnancyCase): Promise<PregnancyCase> {
|
||||
this.cases.set(c.id, c);
|
||||
return c;
|
||||
}
|
||||
|
||||
async findCaseById(id: string): Promise<PregnancyCase | null> {
|
||||
return this.cases.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findCaseByPatient(patientId: string): Promise<PregnancyCase | null> {
|
||||
for (const c of this.cases.values()) {
|
||||
if (c.patientId === patientId) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async savePlan(p: CarePlan): Promise<CarePlan> {
|
||||
const idx = this.plans.findIndex((x) => x.id === p.id);
|
||||
if (idx >= 0) this.plans[idx] = p;
|
||||
else this.plans.push(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
async findPlansByCase(caseId: string): Promise<CarePlan[]> {
|
||||
return this.plans.filter((p) => p.caseId === caseId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { CaseflowService } from './caseflow.service';
|
||||
import { InMemoryCaseflowRepository } from './caseflow.repository';
|
||||
|
||||
describe('CaseflowService(个案管理流程)', () => {
|
||||
let service: CaseflowService;
|
||||
const patientId = 'patient-1';
|
||||
|
||||
beforeEach(() => {
|
||||
service = new CaseflowService(new InMemoryCaseflowRepository());
|
||||
});
|
||||
|
||||
it('开案 → screening 阶段、open 状态', async () => {
|
||||
const c = await service.openCase(patientId, 'low');
|
||||
expect(c.stage).toBe('screening');
|
||||
expect(c.status).toBe('open');
|
||||
});
|
||||
|
||||
it('重复开案返回同一个案', async () => {
|
||||
const a = await service.openCase(patientId, 'low');
|
||||
const b = await service.openCase(patientId, 'low');
|
||||
expect(b.id).toBe(a.id);
|
||||
});
|
||||
|
||||
it('合法流转推进阶段并记录历史', async () => {
|
||||
const c = await service.openCase(patientId, 'low');
|
||||
const advanced = await service.advanceStage(c.id, 'assessment', '开始评估');
|
||||
expect(advanced.stage).toBe('assessment');
|
||||
expect(advanced.history).toHaveLength(1);
|
||||
expect(advanced.history[0].to).toBe('assessment');
|
||||
});
|
||||
|
||||
it('非法流转抛错', async () => {
|
||||
const c = await service.openCase(patientId, 'low');
|
||||
await expect(service.advanceStage(c.id, 'planning', 'x')).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('流转到 transition 关闭个案', async () => {
|
||||
const c = await service.openCase(patientId, 'low');
|
||||
await service.advanceStage(c.id, 'assessment', '');
|
||||
await service.advanceStage(c.id, 'risk_stratification', '');
|
||||
await service.advanceStage(c.id, 'planning', '');
|
||||
await service.advanceStage(c.id, 'implementation', '');
|
||||
await service.advanceStage(c.id, 'monitoring', '');
|
||||
await service.advanceStage(c.id, 'evaluation', '');
|
||||
const closed = await service.advanceStage(c.id, 'transition', '达标转出');
|
||||
expect(closed.status).toBe('closed');
|
||||
});
|
||||
|
||||
it('onAlert:无个案时自动开案并更新风险', async () => {
|
||||
const c = await service.onAlert(patientId, 'medium');
|
||||
expect(c.riskLevel).toBe('medium');
|
||||
});
|
||||
|
||||
it('onAlert:监测中收到高风险 → 回到评估', async () => {
|
||||
const c = await service.openCase(patientId, 'low');
|
||||
await service.advanceStage(c.id, 'assessment', '');
|
||||
await service.advanceStage(c.id, 'risk_stratification', '');
|
||||
await service.advanceStage(c.id, 'planning', '');
|
||||
await service.advanceStage(c.id, 'implementation', '');
|
||||
await service.advanceStage(c.id, 'monitoring', '');
|
||||
const after = await service.onAlert(patientId, 'high');
|
||||
expect(after.stage).toBe('assessment');
|
||||
expect(after.riskLevel).toBe('high');
|
||||
});
|
||||
|
||||
it('制定照护计划', async () => {
|
||||
const c = await service.openCase(patientId, 'medium');
|
||||
const plan = await service.createCarePlan(c.id, {
|
||||
goals: ['空腹血糖控制在 5.1 以下'],
|
||||
interventions: [{ kind: 'lifestyle', description: '低糖饮食 + 餐后散步' }],
|
||||
followUpFrequency: 'weekly',
|
||||
});
|
||||
expect(plan.goals).toHaveLength(1);
|
||||
const plans = await service.listCarePlans(c.id);
|
||||
expect(plans).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('照护计划无目标 → 抛错', async () => {
|
||||
const c = await service.openCase(patientId, 'low');
|
||||
await expect(
|
||||
service.createCarePlan(c.id, { goals: [], interventions: [], followUpFrequency: 'weekly' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('指派个案管理师', async () => {
|
||||
const c = await service.openCase(patientId, 'low');
|
||||
const assigned = await service.assignManager(c.id, 'cm-1');
|
||||
expect(assigned.caseManagerId).toBe('cm-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { CaseflowRepository } from './caseflow.repository';
|
||||
import { CaseStage, PregnancyCase, RiskLevel } from './case.types';
|
||||
import { CarePlan, Intervention } from './care-plan.types';
|
||||
import { canTransition, stageOnAlert } from './case-state-machine';
|
||||
|
||||
export interface CreateCarePlanInput {
|
||||
goals: string[];
|
||||
interventions: Intervention[];
|
||||
followUpFrequency: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 个案管理服务(REQ-6)。
|
||||
* 负责个案生命周期流转、预警驱动的阶段调整、照护计划制定。
|
||||
*/
|
||||
@Injectable()
|
||||
export class CaseflowService {
|
||||
constructor(private readonly repo: CaseflowRepository) {}
|
||||
|
||||
/** 建档后开案(若已存在则返回现有个案) */
|
||||
async openCase(patientId: string, riskLevel: RiskLevel): Promise<PregnancyCase> {
|
||||
const existing = await this.repo.findCaseByPatient(patientId);
|
||||
if (existing) return existing;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const c: PregnancyCase = {
|
||||
id: randomUUID(),
|
||||
patientId,
|
||||
caseManagerId: null,
|
||||
stage: 'screening',
|
||||
status: 'open',
|
||||
riskLevel,
|
||||
history: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
return this.repo.saveCase(c);
|
||||
}
|
||||
|
||||
async getCaseByPatient(patientId: string): Promise<PregnancyCase> {
|
||||
const c = await this.repo.findCaseByPatient(patientId);
|
||||
if (!c) throw new NotFoundException('个案不存在,请先开案');
|
||||
return c;
|
||||
}
|
||||
|
||||
/** 指派个案管理师 */
|
||||
async assignManager(caseId: string, caseManagerId: string): Promise<PregnancyCase> {
|
||||
const c = await this.requireCase(caseId);
|
||||
c.caseManagerId = caseManagerId;
|
||||
c.updatedAt = new Date().toISOString();
|
||||
return this.repo.saveCase(c);
|
||||
}
|
||||
|
||||
/** 推进阶段(REQ-6.2),非法流转抛错 */
|
||||
async advanceStage(caseId: string, to: CaseStage, reason: string): Promise<PregnancyCase> {
|
||||
const c = await this.requireCase(caseId);
|
||||
if (c.status === 'closed') {
|
||||
throw new BadRequestException('个案已关闭');
|
||||
}
|
||||
if (!canTransition(c.stage, to)) {
|
||||
throw new BadRequestException(`不允许的流转:${c.stage} → ${to}`);
|
||||
}
|
||||
this.applyTransition(c, to, reason);
|
||||
if (to === 'transition') {
|
||||
c.status = 'closed';
|
||||
}
|
||||
return this.repo.saveCase(c);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预警驱动的流转(REQ-6.2)。
|
||||
* 确保个案存在,更新风险等级,必要时回到评估阶段交由管理师处置。
|
||||
*/
|
||||
async onAlert(patientId: string, level: RiskLevel): Promise<PregnancyCase> {
|
||||
let c = await this.repo.findCaseByPatient(patientId);
|
||||
if (!c) {
|
||||
c = await this.openCase(patientId, level);
|
||||
}
|
||||
c.riskLevel = level;
|
||||
const target = stageOnAlert(c.stage, level);
|
||||
if (target !== c.stage) {
|
||||
this.applyTransition(c, target, `预警(${level})触发重新评估`);
|
||||
} else {
|
||||
c.updatedAt = new Date().toISOString();
|
||||
}
|
||||
return this.repo.saveCase(c);
|
||||
}
|
||||
|
||||
/** 制定照护计划(REQ-6.3) */
|
||||
async createCarePlan(caseId: string, input: CreateCarePlanInput): Promise<CarePlan> {
|
||||
const c = await this.requireCase(caseId);
|
||||
if (!input.goals?.length) {
|
||||
throw new BadRequestException('照护计划需至少一个目标');
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const plan: CarePlan = {
|
||||
id: randomUUID(),
|
||||
caseId: c.id,
|
||||
patientId: c.patientId,
|
||||
goals: input.goals,
|
||||
interventions: input.interventions ?? [],
|
||||
followUpFrequency: input.followUpFrequency,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
return this.repo.savePlan(plan);
|
||||
}
|
||||
|
||||
listCarePlans(caseId: string): Promise<CarePlan[]> {
|
||||
return this.repo.findPlansByCase(caseId);
|
||||
}
|
||||
|
||||
private applyTransition(c: PregnancyCase, to: CaseStage, reason: string): void {
|
||||
const at = new Date().toISOString();
|
||||
c.history.push({ at, from: c.stage, to, reason });
|
||||
c.stage = to;
|
||||
c.updatedAt = at;
|
||||
}
|
||||
|
||||
private async requireCase(caseId: string): Promise<PregnancyCase> {
|
||||
const c = await this.repo.findCaseById(caseId);
|
||||
if (!c) throw new NotFoundException('个案不存在');
|
||||
return c;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Pool } from 'pg';
|
||||
import { CaseflowRepository } from './caseflow.repository';
|
||||
import { CaseEvent, CaseStage, CaseStatus, PregnancyCase, RiskLevel } from './case.types';
|
||||
import { CarePlan, CarePlanStatus, Intervention } from './care-plan.types';
|
||||
import { FieldSealer } from '../../common/crypto/field-sealer';
|
||||
|
||||
interface SensitiveCase {
|
||||
history: CaseEvent[];
|
||||
}
|
||||
interface SensitivePlan {
|
||||
goals: string[];
|
||||
interventions: Intervention[];
|
||||
followUpFrequency: string;
|
||||
}
|
||||
|
||||
interface CaseRow {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
case_manager_id: string | null;
|
||||
stage: string;
|
||||
status: string;
|
||||
risk_level: string;
|
||||
enc: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
interface PlanRow {
|
||||
id: string;
|
||||
case_id: string;
|
||||
patient_id: string;
|
||||
status: string;
|
||||
enc: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
const iso = (v: Date | string): string => (v instanceof Date ? v.toISOString() : String(v));
|
||||
|
||||
/** PostgreSQL 个案/照护计划仓储(T-1.2)。流转历史与计划内容加密入 enc。 */
|
||||
export class PostgresCaseflowRepository extends CaseflowRepository {
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly sealer: FieldSealer,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async saveCase(c: PregnancyCase): Promise<PregnancyCase> {
|
||||
const sensitive: SensitiveCase = { history: c.history };
|
||||
await this.pool.query(
|
||||
`INSERT INTO cases (id, patient_id, case_manager_id, stage, status, risk_level, enc, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
case_manager_id = EXCLUDED.case_manager_id, stage = EXCLUDED.stage,
|
||||
status = EXCLUDED.status, risk_level = EXCLUDED.risk_level,
|
||||
enc = EXCLUDED.enc, updated_at = EXCLUDED.updated_at`,
|
||||
[
|
||||
c.id,
|
||||
c.patientId,
|
||||
c.caseManagerId,
|
||||
c.stage,
|
||||
c.status,
|
||||
c.riskLevel,
|
||||
this.sealer.seal(sensitive),
|
||||
c.createdAt,
|
||||
c.updatedAt,
|
||||
],
|
||||
);
|
||||
return c;
|
||||
}
|
||||
|
||||
async findCaseById(id: string): Promise<PregnancyCase | null> {
|
||||
const res = await this.pool.query<CaseRow>('SELECT * FROM cases WHERE id = $1', [id]);
|
||||
return res.rows[0] ? this.toCase(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findCaseByPatient(patientId: string): Promise<PregnancyCase | null> {
|
||||
const res = await this.pool.query<CaseRow>(
|
||||
'SELECT * FROM cases WHERE patient_id = $1 ORDER BY created_at LIMIT 1',
|
||||
[patientId],
|
||||
);
|
||||
return res.rows[0] ? this.toCase(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async savePlan(p: CarePlan): Promise<CarePlan> {
|
||||
const sensitive: SensitivePlan = {
|
||||
goals: p.goals,
|
||||
interventions: p.interventions,
|
||||
followUpFrequency: p.followUpFrequency,
|
||||
};
|
||||
await this.pool.query(
|
||||
`INSERT INTO care_plans (id, case_id, patient_id, status, enc, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, enc = EXCLUDED.enc, updated_at = EXCLUDED.updated_at`,
|
||||
[p.id, p.caseId, p.patientId, p.status, this.sealer.seal(sensitive), p.createdAt, p.updatedAt],
|
||||
);
|
||||
return p;
|
||||
}
|
||||
|
||||
async findPlansByCase(caseId: string): Promise<CarePlan[]> {
|
||||
const res = await this.pool.query<PlanRow>(
|
||||
'SELECT * FROM care_plans WHERE case_id = $1 ORDER BY created_at',
|
||||
[caseId],
|
||||
);
|
||||
return res.rows.map((r) => this.toPlan(r));
|
||||
}
|
||||
|
||||
private toCase(row: CaseRow): PregnancyCase {
|
||||
const sensitive = this.sealer.open<SensitiveCase>(row.enc);
|
||||
return {
|
||||
id: row.id,
|
||||
patientId: row.patient_id,
|
||||
caseManagerId: row.case_manager_id,
|
||||
stage: row.stage as CaseStage,
|
||||
status: row.status as CaseStatus,
|
||||
riskLevel: row.risk_level as RiskLevel,
|
||||
history: sensitive.history,
|
||||
createdAt: iso(row.created_at),
|
||||
updatedAt: iso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
private toPlan(row: PlanRow): CarePlan {
|
||||
const sensitive = this.sealer.open<SensitivePlan>(row.enc);
|
||||
return {
|
||||
id: row.id,
|
||||
caseId: row.case_id,
|
||||
patientId: row.patient_id,
|
||||
goals: sensitive.goals,
|
||||
interventions: sensitive.interventions,
|
||||
followUpFrequency: sensitive.followUpFrequency,
|
||||
status: row.status as CarePlanStatus,
|
||||
createdAt: iso(row.created_at),
|
||||
updatedAt: iso(row.updated_at),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { DispositionStatus } from './disposition.types';
|
||||
|
||||
/**
|
||||
* 处置单状态机(REQ-D1)。纯逻辑,便于测试。
|
||||
* draft → pending_confirmation | in_progress
|
||||
* pending_confirmation → in_progress(经人工确认)
|
||||
* in_progress → following_up | closed
|
||||
* following_up → closed
|
||||
*/
|
||||
const TRANSITIONS: Record<DispositionStatus, DispositionStatus[]> = {
|
||||
draft: ['pending_confirmation', 'in_progress'],
|
||||
pending_confirmation: ['in_progress'],
|
||||
in_progress: ['following_up', 'closed'],
|
||||
following_up: ['closed'],
|
||||
closed: [],
|
||||
};
|
||||
|
||||
export function canTransition(from: DispositionStatus, to: DispositionStatus): boolean {
|
||||
return TRANSITIONS[from]?.includes(to) ?? false;
|
||||
}
|
||||
|
||||
/** 创建时的初始状态:中/高风险需确认,低风险直接进入执行中。 */
|
||||
export function initialStatus(requiresConfirmation: boolean): DispositionStatus {
|
||||
return requiresConfirmation ? 'pending_confirmation' : 'in_progress';
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import {
|
||||
CreateActionInput,
|
||||
DispositionService,
|
||||
} from './disposition.service';
|
||||
import {
|
||||
ClosureOutcome,
|
||||
Disposition,
|
||||
DispositionSourceType,
|
||||
RiskLevel,
|
||||
} from './disposition.types';
|
||||
import { RequireCaps } from '../../common/auth/capabilities.decorator';
|
||||
import { CurrentUser } from '../../common/auth/current-user.decorator';
|
||||
import { RequestUser } from '../../common/auth/request-user';
|
||||
|
||||
@Controller()
|
||||
export class DispositionController {
|
||||
constructor(private readonly disposition: DispositionService) {}
|
||||
|
||||
/** 新建处置单(含一个/一组动作) */
|
||||
@Post('patients/:patientId/dispositions')
|
||||
@RequireCaps('disposition:create')
|
||||
create(
|
||||
@Param('patientId') patientId: string,
|
||||
@Body()
|
||||
body: {
|
||||
caseId: string;
|
||||
sourceType: DispositionSourceType;
|
||||
sourceId?: string;
|
||||
title: string;
|
||||
riskLevelAtCreation: RiskLevel;
|
||||
actions: CreateActionInput[];
|
||||
supersedesId?: string;
|
||||
},
|
||||
@CurrentUser() user?: RequestUser,
|
||||
): Promise<Disposition> {
|
||||
return this.disposition.create({ ...body, patientId }, user?.id ?? 'system');
|
||||
}
|
||||
|
||||
@Get('patients/:patientId/dispositions')
|
||||
@RequireCaps('disposition:read')
|
||||
list(@Param('patientId') patientId: string): Promise<Disposition[]> {
|
||||
return this.disposition.listByPatient(patientId);
|
||||
}
|
||||
|
||||
@Get('dispositions/:id')
|
||||
@RequireCaps('disposition:read')
|
||||
get(@Param('id') id: string): Promise<Disposition> {
|
||||
return this.disposition.getById(id);
|
||||
}
|
||||
|
||||
/** 确认中/高风险处置单(人工兜底) */
|
||||
@Post('dispositions/:id/confirm')
|
||||
@RequireCaps('disposition:confirm')
|
||||
confirm(@Param('id') id: string, @CurrentUser() user?: RequestUser): Promise<Disposition> {
|
||||
return this.disposition.confirm(id, user?.id ?? 'system');
|
||||
}
|
||||
|
||||
/** 执行单个处置动作并回执 */
|
||||
@Post('dispositions/:id/actions/:actionId/execute')
|
||||
@RequireCaps('disposition:execute')
|
||||
execute(
|
||||
@Param('id') id: string,
|
||||
@Param('actionId') actionId: string,
|
||||
@Body() body: { linkedEntityId?: string; resultNote?: string },
|
||||
): Promise<Disposition> {
|
||||
return this.disposition.executeAction(id, actionId, body);
|
||||
}
|
||||
|
||||
/** 闭环处置单(记录达标/未达标/升级) */
|
||||
@Post('dispositions/:id/close')
|
||||
@RequireCaps('disposition:execute')
|
||||
close(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { outcome: ClosureOutcome },
|
||||
): Promise<Disposition> {
|
||||
return this.disposition.close(id, body.outcome);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Pool } from 'pg';
|
||||
import { DispositionService } from './disposition.service';
|
||||
import { DispositionController } from './disposition.controller';
|
||||
import {
|
||||
DispositionRepository,
|
||||
InMemoryDispositionRepository,
|
||||
} from './disposition.repository';
|
||||
import { PostgresDispositionRepository } from './postgres-disposition.repository';
|
||||
import { PG_POOL } from '../../common/db/db.tokens';
|
||||
import { createSealerFromEnv } from '../../common/crypto/field-sealer';
|
||||
import { FollowupModule } from '../followup/followup.module';
|
||||
|
||||
@Module({
|
||||
imports: [FollowupModule],
|
||||
controllers: [DispositionController],
|
||||
providers: [
|
||||
DispositionService,
|
||||
{
|
||||
provide: DispositionRepository,
|
||||
useFactory: (pool: Pool | null): DispositionRepository =>
|
||||
pool
|
||||
? new PostgresDispositionRepository(pool, createSealerFromEnv())
|
||||
: new InMemoryDispositionRepository(),
|
||||
inject: [PG_POOL],
|
||||
},
|
||||
],
|
||||
exports: [DispositionService],
|
||||
})
|
||||
export class DispositionModule {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Disposition } from './disposition.types';
|
||||
|
||||
/** 处置单仓储抽象(内存实现 + PG 实现)。 */
|
||||
export abstract class DispositionRepository {
|
||||
abstract save(d: Disposition): Promise<Disposition>;
|
||||
abstract findById(id: string): Promise<Disposition | null>;
|
||||
abstract findByPatient(patientId: string): Promise<Disposition[]>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InMemoryDispositionRepository extends DispositionRepository {
|
||||
private readonly items = new Map<string, Disposition>();
|
||||
|
||||
async save(d: Disposition): Promise<Disposition> {
|
||||
this.items.set(d.id, d);
|
||||
return d;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Disposition | null> {
|
||||
return this.items.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findByPatient(patientId: string): Promise<Disposition[]> {
|
||||
return [...this.items.values()]
|
||||
.filter((d) => d.patientId === patientId)
|
||||
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { DispositionRepository } from './disposition.repository';
|
||||
import {
|
||||
ActionKind,
|
||||
ClosureOutcome,
|
||||
Disposition,
|
||||
DispositionSourceType,
|
||||
DispositionStatus,
|
||||
RiskLevel,
|
||||
} from './disposition.types';
|
||||
import { canTransition, initialStatus } from './disposition-state-machine';
|
||||
import { FollowupService } from '../followup/followup.service';
|
||||
import { TargetOperator } from '../followup/followup.types';
|
||||
|
||||
export interface CreateActionInput {
|
||||
kind: ActionKind;
|
||||
params?: Record<string, unknown>;
|
||||
assigneeId?: string | null;
|
||||
dueAt?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateDispositionInput {
|
||||
caseId: string;
|
||||
patientId: string;
|
||||
sourceType: DispositionSourceType;
|
||||
sourceId?: string | null;
|
||||
title: string;
|
||||
riskLevelAtCreation: RiskLevel;
|
||||
actions: CreateActionInput[];
|
||||
supersedesId?: string | null;
|
||||
}
|
||||
|
||||
export interface ExecuteActionPatch {
|
||||
linkedEntityId?: string | null;
|
||||
resultNote?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处置单服务(REQ-D1)。
|
||||
* 负责处置单创建(含动作组)、风险门控确认、动作执行与回执、闭环。
|
||||
* 安全:中/高风险须人工确认方可执行(REQ-D1.2 / REQ-10.3)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class DispositionService {
|
||||
constructor(
|
||||
private readonly repo: DispositionRepository,
|
||||
private readonly followup: FollowupService,
|
||||
) {}
|
||||
|
||||
async create(input: CreateDispositionInput, createdBy: string): Promise<Disposition> {
|
||||
if (!input.title?.trim()) {
|
||||
throw new BadRequestException('处置单需标题');
|
||||
}
|
||||
if (!input.actions?.length) {
|
||||
throw new BadRequestException('处置单需至少一个处置动作');
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const requiresConfirmation = input.riskLevelAtCreation !== 'low';
|
||||
const d: Disposition = {
|
||||
id: randomUUID(),
|
||||
caseId: input.caseId,
|
||||
patientId: input.patientId,
|
||||
sourceType: input.sourceType,
|
||||
sourceId: input.sourceId ?? null,
|
||||
title: input.title.trim(),
|
||||
riskLevelAtCreation: input.riskLevelAtCreation,
|
||||
requiresConfirmation,
|
||||
status: initialStatus(requiresConfirmation),
|
||||
closureOutcome: null,
|
||||
supersedesId: input.supersedesId ?? null,
|
||||
createdBy,
|
||||
confirmedBy: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
closedAt: null,
|
||||
actions: input.actions.map((a) => ({
|
||||
id: randomUUID(),
|
||||
kind: a.kind,
|
||||
params: a.params ?? {},
|
||||
status: 'planned' as const,
|
||||
assigneeId: a.assigneeId ?? null,
|
||||
dueAt: a.dueAt ?? null,
|
||||
executedAt: null,
|
||||
resultNote: null,
|
||||
linkedEntityId: null,
|
||||
})),
|
||||
};
|
||||
return this.repo.save(d);
|
||||
}
|
||||
|
||||
listByPatient(patientId: string): Promise<Disposition[]> {
|
||||
return this.repo.findByPatient(patientId);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Disposition> {
|
||||
return this.require(id);
|
||||
}
|
||||
|
||||
/** 人工确认中/高风险处置单(医生/管理师兜底),确认后方可执行。 */
|
||||
async confirm(id: string, confirmedBy: string): Promise<Disposition> {
|
||||
const d = await this.require(id);
|
||||
if (d.status !== 'pending_confirmation') {
|
||||
throw new BadRequestException('仅「待确认」状态的处置单可确认');
|
||||
}
|
||||
this.setStatus(d, 'in_progress');
|
||||
d.confirmedBy = confirmedBy;
|
||||
return this.repo.save(d);
|
||||
}
|
||||
|
||||
/** 执行单个处置动作并回执;中/高风险未确认前禁止执行。 */
|
||||
async executeAction(
|
||||
dispositionId: string,
|
||||
actionId: string,
|
||||
patch: ExecuteActionPatch = {},
|
||||
): Promise<Disposition> {
|
||||
const d = await this.require(dispositionId);
|
||||
if (d.status === 'pending_confirmation') {
|
||||
throw new BadRequestException('处置单需先经人工确认方可执行(REQ-10.3 人工兜底)');
|
||||
}
|
||||
if (d.status === 'closed') {
|
||||
throw new BadRequestException('处置单已闭环');
|
||||
}
|
||||
const action = d.actions.find((a) => a.id === actionId);
|
||||
if (!action) {
|
||||
throw new NotFoundException('处置动作不存在');
|
||||
}
|
||||
action.status = 'executed';
|
||||
action.executedAt = new Date().toISOString();
|
||||
if (patch.linkedEntityId !== undefined) action.linkedEntityId = patch.linkedEntityId;
|
||||
if (patch.resultNote !== undefined) action.resultNote = patch.resultNote;
|
||||
|
||||
// 复测动作执行 → 自动生成跟进项(REQ-D2),并回填 linkedEntityId
|
||||
if (action.kind === 'recheck' && !action.linkedEntityId) {
|
||||
const indicator = String(action.params?.indicator ?? '');
|
||||
if (indicator) {
|
||||
const fu = await this.followup.createForRecheck({
|
||||
dispositionId: d.id,
|
||||
patientId: d.patientId,
|
||||
indicator,
|
||||
targetOperator: action.params?.targetOperator as TargetOperator | undefined,
|
||||
targetValue:
|
||||
typeof action.params?.targetValue === 'number'
|
||||
? (action.params.targetValue as number)
|
||||
: undefined,
|
||||
windowDays:
|
||||
typeof action.params?.windowDays === 'number'
|
||||
? (action.params.windowDays as number)
|
||||
: undefined,
|
||||
});
|
||||
if (fu) action.linkedEntityId = fu.id;
|
||||
}
|
||||
}
|
||||
|
||||
// 全部动作落地(执行或跳过)→ 进入「跟进中」
|
||||
const allSettled = d.actions.every((a) => a.status === 'executed' || a.status === 'skipped');
|
||||
if (allSettled && d.status === 'in_progress') {
|
||||
this.setStatus(d, 'following_up');
|
||||
} else {
|
||||
d.updatedAt = new Date().toISOString();
|
||||
}
|
||||
return this.repo.save(d);
|
||||
}
|
||||
|
||||
/** 闭环处置单并记录结果(达标/未达标/升级)。 */
|
||||
async close(id: string, outcome: ClosureOutcome): Promise<Disposition> {
|
||||
const d = await this.require(id);
|
||||
if (d.status !== 'in_progress' && d.status !== 'following_up') {
|
||||
throw new BadRequestException('仅「执行中/跟进中」的处置单可闭环');
|
||||
}
|
||||
this.setStatus(d, 'closed');
|
||||
d.closureOutcome = outcome;
|
||||
d.closedAt = new Date().toISOString();
|
||||
return this.repo.save(d);
|
||||
}
|
||||
|
||||
private setStatus(d: Disposition, to: DispositionStatus): void {
|
||||
if (!canTransition(d.status, to)) {
|
||||
throw new BadRequestException(`非法状态流转:${d.status} → ${to}`);
|
||||
}
|
||||
d.status = to;
|
||||
d.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
private async require(id: string): Promise<Disposition> {
|
||||
const d = await this.repo.findById(id);
|
||||
if (!d) throw new NotFoundException('处置单不存在');
|
||||
return d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { InMemoryDispositionRepository } from './disposition.repository';
|
||||
import { CreateDispositionInput, DispositionService } from './disposition.service';
|
||||
import { canTransition, initialStatus } from './disposition-state-machine';
|
||||
import { FollowupService } from '../followup/followup.service';
|
||||
import { InMemoryFollowupRepository } from '../followup/followup.repository';
|
||||
|
||||
function baseInput(overrides: Partial<CreateDispositionInput> = {}): CreateDispositionInput {
|
||||
return {
|
||||
caseId: 'case-1',
|
||||
patientId: 'patient-1',
|
||||
sourceType: 'alert',
|
||||
sourceId: 'alert-1',
|
||||
title: '收缩压 150 处置',
|
||||
riskLevelAtCreation: 'medium',
|
||||
actions: [
|
||||
{ kind: 'reminder', params: { type: 'rest', message: '注意休息' } },
|
||||
{ kind: 'recheck', params: { indicator: 'systolic_bp', windowDays: 2 } },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('disposition state machine', () => {
|
||||
it('allows lawful transitions only', () => {
|
||||
expect(canTransition('pending_confirmation', 'in_progress')).toBe(true);
|
||||
expect(canTransition('in_progress', 'following_up')).toBe(true);
|
||||
expect(canTransition('in_progress', 'closed')).toBe(true);
|
||||
expect(canTransition('following_up', 'closed')).toBe(true);
|
||||
// 非法
|
||||
expect(canTransition('pending_confirmation', 'closed')).toBe(false);
|
||||
expect(canTransition('closed', 'in_progress')).toBe(false);
|
||||
expect(canTransition('draft', 'closed')).toBe(false);
|
||||
});
|
||||
|
||||
it('initial status gates on confirmation', () => {
|
||||
expect(initialStatus(true)).toBe('pending_confirmation');
|
||||
expect(initialStatus(false)).toBe('in_progress');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DispositionService', () => {
|
||||
let svc: DispositionService;
|
||||
let followup: FollowupService;
|
||||
|
||||
beforeEach(() => {
|
||||
followup = new FollowupService(new InMemoryFollowupRepository());
|
||||
svc = new DispositionService(new InMemoryDispositionRepository(), followup);
|
||||
});
|
||||
|
||||
it('低风险处置单无需确认,直接进入执行中', async () => {
|
||||
const d = await svc.create(baseInput({ riskLevelAtCreation: 'low' }), 'mgr-1');
|
||||
expect(d.requiresConfirmation).toBe(false);
|
||||
expect(d.status).toBe('in_progress');
|
||||
expect(d.actions).toHaveLength(2);
|
||||
expect(d.actions[0].status).toBe('planned');
|
||||
expect(d.createdBy).toBe('mgr-1');
|
||||
});
|
||||
|
||||
it('中/高风险处置单需人工确认', async () => {
|
||||
const d = await svc.create(baseInput({ riskLevelAtCreation: 'high' }), 'mgr-1');
|
||||
expect(d.requiresConfirmation).toBe(true);
|
||||
expect(d.status).toBe('pending_confirmation');
|
||||
});
|
||||
|
||||
it('拒绝无动作的处置单', async () => {
|
||||
await expect(svc.create(baseInput({ actions: [] }), 'mgr-1')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('中/高风险未确认前禁止执行动作(人工兜底)', async () => {
|
||||
const d = await svc.create(baseInput({ riskLevelAtCreation: 'medium' }), 'mgr-1');
|
||||
await expect(svc.executeAction(d.id, d.actions[0].id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('确认后可执行;全部动作落地后进入跟进中', async () => {
|
||||
const created = await svc.create(baseInput({ riskLevelAtCreation: 'medium' }), 'mgr-1');
|
||||
const confirmed = await svc.confirm(created.id, 'doc-1');
|
||||
expect(confirmed.status).toBe('in_progress');
|
||||
expect(confirmed.confirmedBy).toBe('doc-1');
|
||||
|
||||
await svc.executeAction(created.id, created.actions[0].id, { linkedEntityId: 'reminder-9' });
|
||||
const afterAll = await svc.executeAction(created.id, created.actions[1].id);
|
||||
expect(afterAll.status).toBe('following_up');
|
||||
expect(afterAll.actions.every((a) => a.status === 'executed')).toBe(true);
|
||||
expect(afterAll.actions[0].linkedEntityId).toBe('reminder-9');
|
||||
// recheck 动作执行后自动生成跟进项并回填 linkedEntityId(REQ-D2)
|
||||
const recheck = afterAll.actions.find((a) => a.kind === 'recheck');
|
||||
expect(recheck?.linkedEntityId).toBeTruthy();
|
||||
const fus = await followup.listByDisposition(created.id);
|
||||
expect(fus).toHaveLength(1);
|
||||
expect(fus[0].indicator).toBe('systolic_bp');
|
||||
});
|
||||
|
||||
it('重复确认非待确认单抛错', async () => {
|
||||
const d = await svc.create(baseInput({ riskLevelAtCreation: 'low' }), 'mgr-1');
|
||||
await expect(svc.confirm(d.id, 'doc-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('闭环记录结果', async () => {
|
||||
const d = await svc.create(baseInput({ riskLevelAtCreation: 'low' }), 'mgr-1');
|
||||
const closed = await svc.close(d.id, 'met');
|
||||
expect(closed.status).toBe('closed');
|
||||
expect(closed.closureOutcome).toBe('met');
|
||||
expect(closed.closedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('未知处置单抛 NotFound', async () => {
|
||||
await expect(svc.getById('nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 处置单领域模型(REQ-D1,详见 6-exec-PCM.md §4)。
|
||||
* 处置单 = 针对某预警/风险/红旗/情绪打包的一个或一组处置动作,按状态机跟踪并可闭环。
|
||||
*/
|
||||
|
||||
export type RiskLevel = 'low' | 'medium' | 'high';
|
||||
|
||||
/** 处置单触发来源 */
|
||||
export type DispositionSourceType = 'alert' | 'risk' | 'redflag' | 'emotion' | 'manual';
|
||||
|
||||
/** 处置单状态机(REQ-D1) */
|
||||
export type DispositionStatus =
|
||||
| 'draft' // 草拟(AI 生成或手工新建,未提交)
|
||||
| 'pending_confirmation' // 待确认(中/高风险必经;低风险跳过)
|
||||
| 'in_progress' // 执行中(动作陆续落地)
|
||||
| 'following_up' // 跟进中(动作已落地,等复测/到期评估)
|
||||
| 'closed'; // 已闭环
|
||||
|
||||
/** 闭环结果 */
|
||||
export type ClosureOutcome = 'met' | 'not_met' | 'escalated';
|
||||
|
||||
/** 处置动作类型(对齐基础构想干预分类 + 协调/教育/关怀) */
|
||||
export type ActionKind =
|
||||
| 'reminder' // 下发提醒
|
||||
| 'care_plan' // 制定/调整照护计划
|
||||
| 'monitor_freq' // 调整监测频率/指标
|
||||
| 'recheck' // 安排复测/产检(生成跟进项)
|
||||
| 'education' // 推送健康教育
|
||||
| 'emotional_care' // 身心关怀/情绪疏导
|
||||
| 'referral' // 转诊
|
||||
| 'consult' // 医生会诊
|
||||
| 'medication'; // 用药提醒(非开方)
|
||||
|
||||
export type ActionStatus = 'planned' | 'executed' | 'skipped' | 'failed';
|
||||
|
||||
export interface DispositionAction {
|
||||
id: string;
|
||||
kind: ActionKind;
|
||||
/** 依 kind 而定的结构化参数(如 reminder: {type,message};recheck: {indicator,windowDays}) */
|
||||
params: Record<string, unknown>;
|
||||
status: ActionStatus;
|
||||
assigneeId?: string | null;
|
||||
dueAt?: string | null;
|
||||
executedAt?: string | null;
|
||||
resultNote?: string | null;
|
||||
/** 落地后生成的实体 ID(reminderId/carePlanId/referralId/followUpId) */
|
||||
linkedEntityId?: string | null;
|
||||
}
|
||||
|
||||
export interface Disposition {
|
||||
id: string;
|
||||
caseId: string;
|
||||
patientId: string;
|
||||
sourceType: DispositionSourceType;
|
||||
/** 触发来源实体 ID(预警/风险/红旗/情绪),可追溯 */
|
||||
sourceId?: string | null;
|
||||
title: string;
|
||||
/** 创建时风险等级(决定是否需人工确认) */
|
||||
riskLevelAtCreation: RiskLevel;
|
||||
/** 中/高风险 = true:AI 建议不自动执行,须人工确认(REQ-D1.2 / REQ-10.3) */
|
||||
requiresConfirmation: boolean;
|
||||
status: DispositionStatus;
|
||||
closureOutcome?: ClosureOutcome | null;
|
||||
/** 未达标再处置时关联的原处置单 ID */
|
||||
supersedesId?: string | null;
|
||||
createdBy: string;
|
||||
confirmedBy?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
closedAt?: string | null;
|
||||
actions: DispositionAction[];
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Pool } from 'pg';
|
||||
import { DispositionRepository } from './disposition.repository';
|
||||
import {
|
||||
ClosureOutcome,
|
||||
Disposition,
|
||||
DispositionAction,
|
||||
DispositionSourceType,
|
||||
DispositionStatus,
|
||||
RiskLevel,
|
||||
} from './disposition.types';
|
||||
import { FieldSealer } from '../../common/crypto/field-sealer';
|
||||
|
||||
/** 加密入 enc 的敏感内容:标题、来源ID、动作明细(含参数/回执说明)。 */
|
||||
interface SensitiveDisposition {
|
||||
title: string;
|
||||
sourceId: string | null;
|
||||
actions: DispositionAction[];
|
||||
}
|
||||
|
||||
interface DispositionRow {
|
||||
id: string;
|
||||
case_id: string;
|
||||
patient_id: string;
|
||||
source_type: string;
|
||||
status: string;
|
||||
risk_level: string;
|
||||
requires_confirmation: boolean;
|
||||
closure_outcome: string | null;
|
||||
supersedes_id: string | null;
|
||||
created_by: string;
|
||||
confirmed_by: string | null;
|
||||
enc: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
closed_at: Date | null;
|
||||
}
|
||||
|
||||
const iso = (v: Date | string): string => (v instanceof Date ? v.toISOString() : String(v));
|
||||
const isoOrNull = (v: Date | string | null): string | null =>
|
||||
v == null ? null : iso(v);
|
||||
|
||||
/** PostgreSQL 处置单仓储(T-D.1 / T-1.2)。敏感内容加密入 enc;状态/风险/人等留列供检索。 */
|
||||
export class PostgresDispositionRepository extends DispositionRepository {
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly sealer: FieldSealer,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async save(d: Disposition): Promise<Disposition> {
|
||||
const sensitive: SensitiveDisposition = {
|
||||
title: d.title,
|
||||
sourceId: d.sourceId ?? null,
|
||||
actions: d.actions,
|
||||
};
|
||||
await this.pool.query(
|
||||
`INSERT INTO dispositions
|
||||
(id, case_id, patient_id, source_type, status, risk_level, requires_confirmation,
|
||||
closure_outcome, supersedes_id, created_by, confirmed_by, enc, created_at, updated_at, closed_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
status = EXCLUDED.status, risk_level = EXCLUDED.risk_level,
|
||||
requires_confirmation = EXCLUDED.requires_confirmation,
|
||||
closure_outcome = EXCLUDED.closure_outcome, confirmed_by = EXCLUDED.confirmed_by,
|
||||
enc = EXCLUDED.enc, updated_at = EXCLUDED.updated_at, closed_at = EXCLUDED.closed_at`,
|
||||
[
|
||||
d.id,
|
||||
d.caseId,
|
||||
d.patientId,
|
||||
d.sourceType,
|
||||
d.status,
|
||||
d.riskLevelAtCreation,
|
||||
d.requiresConfirmation,
|
||||
d.closureOutcome ?? null,
|
||||
d.supersedesId ?? null,
|
||||
d.createdBy,
|
||||
d.confirmedBy ?? null,
|
||||
this.sealer.seal(sensitive),
|
||||
d.createdAt,
|
||||
d.updatedAt,
|
||||
d.closedAt ?? null,
|
||||
],
|
||||
);
|
||||
return d;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Disposition | null> {
|
||||
const res = await this.pool.query<DispositionRow>('SELECT * FROM dispositions WHERE id = $1', [
|
||||
id,
|
||||
]);
|
||||
return res.rows[0] ? this.toDomain(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findByPatient(patientId: string): Promise<Disposition[]> {
|
||||
const res = await this.pool.query<DispositionRow>(
|
||||
'SELECT * FROM dispositions WHERE patient_id = $1 ORDER BY created_at',
|
||||
[patientId],
|
||||
);
|
||||
return res.rows.map((r) => this.toDomain(r));
|
||||
}
|
||||
|
||||
private toDomain(row: DispositionRow): Disposition {
|
||||
const sensitive = this.sealer.open<SensitiveDisposition>(row.enc);
|
||||
return {
|
||||
id: row.id,
|
||||
caseId: row.case_id,
|
||||
patientId: row.patient_id,
|
||||
sourceType: row.source_type as DispositionSourceType,
|
||||
sourceId: sensitive.sourceId,
|
||||
title: sensitive.title,
|
||||
riskLevelAtCreation: row.risk_level as RiskLevel,
|
||||
requiresConfirmation: row.requires_confirmation,
|
||||
status: row.status as DispositionStatus,
|
||||
closureOutcome: (row.closure_outcome as ClosureOutcome | null) ?? null,
|
||||
supersedesId: row.supersedes_id,
|
||||
createdBy: row.created_by,
|
||||
confirmedBy: row.confirmed_by,
|
||||
createdAt: iso(row.created_at),
|
||||
updatedAt: iso(row.updated_at),
|
||||
closedAt: isoOrNull(row.closed_at),
|
||||
actions: sensitive.actions,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Controller, Post, Get, Body, Param } from '@nestjs/common';
|
||||
import { EmotionService } from './emotion.service';
|
||||
import { CreateEmotionDto, EmotionCheckin } from './emotion.types';
|
||||
import { RequireCaps } from '../../common/auth/capabilities.decorator';
|
||||
import { CurrentUser } from '../../common/auth/current-user.decorator';
|
||||
import { RequestUser } from '../../common/auth/request-user';
|
||||
|
||||
@Controller('emotions')
|
||||
export class EmotionController {
|
||||
constructor(private readonly service: EmotionService) {}
|
||||
|
||||
@Post()
|
||||
@RequireCaps('emotion:create')
|
||||
async create(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body() dto: CreateEmotionDto,
|
||||
): Promise<EmotionCheckin> {
|
||||
// 强制关联为当前登录孕妇患者 ID
|
||||
const patientId = user.role === 'patient' ? user.id : dto.patientId;
|
||||
return this.service.create({
|
||||
...dto,
|
||||
patientId,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('patient/:patientId')
|
||||
@RequireCaps('emotion:read')
|
||||
async getByPatient(@Param('patientId') patientId: string): Promise<EmotionCheckin[]> {
|
||||
return this.service.listByPatient(patientId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequireCaps('emotion:read')
|
||||
async getAll(): Promise<EmotionCheckin[]> {
|
||||
return this.service.listAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Pool } from 'pg';
|
||||
import { EmotionController } from './emotion.controller';
|
||||
import { EmotionService } from './emotion.service';
|
||||
import { EmotionRepository, InMemoryEmotionRepository } from './emotion.repository';
|
||||
import { PostgresEmotionRepository } from './postgres-emotion.repository';
|
||||
import { PG_POOL } from '../../common/db/db.tokens';
|
||||
import { createSealerFromEnv } from '../../common/crypto/field-sealer';
|
||||
|
||||
@Module({
|
||||
controllers: [EmotionController],
|
||||
providers: [
|
||||
EmotionService,
|
||||
{
|
||||
provide: EmotionRepository,
|
||||
useFactory: (pool: Pool | null): EmotionRepository =>
|
||||
pool
|
||||
? new PostgresEmotionRepository(pool, createSealerFromEnv())
|
||||
: new InMemoryEmotionRepository(),
|
||||
inject: [PG_POOL],
|
||||
},
|
||||
],
|
||||
exports: [EmotionService],
|
||||
})
|
||||
export class EmotionModule {}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { EmotionCheckin } from './emotion.types';
|
||||
|
||||
export abstract class EmotionRepository {
|
||||
abstract save(e: EmotionCheckin): Promise<EmotionCheckin>;
|
||||
abstract findById(id: string): Promise<EmotionCheckin | null>;
|
||||
abstract findByPatient(patientId: string): Promise<EmotionCheckin[]>;
|
||||
abstract findAll(): Promise<EmotionCheckin[]>;
|
||||
}
|
||||
|
||||
export class InMemoryEmotionRepository extends EmotionRepository {
|
||||
private readonly items = new Map<string, EmotionCheckin>();
|
||||
|
||||
async save(e: EmotionCheckin): Promise<EmotionCheckin> {
|
||||
this.items.set(e.id, { ...e });
|
||||
return e;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<EmotionCheckin | null> {
|
||||
const item = this.items.get(id);
|
||||
return item ? { ...item } : null;
|
||||
}
|
||||
|
||||
async findByPatient(patientId: string): Promise<EmotionCheckin[]> {
|
||||
return Array.from(this.items.values())
|
||||
.filter((e) => e.patientId === patientId)
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
async findAll(): Promise<EmotionCheckin[]> {
|
||||
return Array.from(this.items.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { EmotionRepository } from './emotion.repository';
|
||||
import { CreateEmotionDto, EmotionCheckin, EmotionStatus } from './emotion.types';
|
||||
|
||||
@Injectable()
|
||||
export class EmotionService {
|
||||
constructor(private readonly repo: EmotionRepository) {}
|
||||
|
||||
/** 情绪评级衍生算法与危机关键词过滤(T-D.4) */
|
||||
deriveStatus(score: number, note: string): EmotionStatus {
|
||||
const crisisKeywords = ['自杀', '不想活', '崩溃', '救命', '抑郁', '结束生命', '去死'];
|
||||
const hasCrisisKeyword = crisisKeywords.some((kw) => note.includes(kw));
|
||||
|
||||
if (hasCrisisKeyword || score <= 3) {
|
||||
return 'crisis';
|
||||
}
|
||||
if (score <= 5) {
|
||||
return 'concerning';
|
||||
}
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
async create(dto: CreateEmotionDto): Promise<EmotionCheckin> {
|
||||
if (dto.score < 1 || dto.score > 10) {
|
||||
throw new BadRequestException('情绪打卡评分范围必须为 1 - 10');
|
||||
}
|
||||
|
||||
const status = this.deriveStatus(dto.score, dto.note);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const checkin: EmotionCheckin = {
|
||||
id: randomUUID(),
|
||||
patientId: dto.patientId,
|
||||
score: dto.score,
|
||||
status,
|
||||
note: dto.note,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
return this.repo.save(checkin);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<EmotionCheckin | null> {
|
||||
return this.repo.findById(id);
|
||||
}
|
||||
|
||||
async listByPatient(patientId: string): Promise<EmotionCheckin[]> {
|
||||
return this.repo.findByPatient(patientId);
|
||||
}
|
||||
|
||||
async listAll(): Promise<EmotionCheckin[]> {
|
||||
return this.repo.findAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export type EmotionStatus = 'normal' | 'concerning' | 'crisis';
|
||||
|
||||
export interface EmotionCheckin {
|
||||
id: string;
|
||||
patientId: string;
|
||||
score: number; // 1-10 分,分值越低表示情绪越压抑或焦虑
|
||||
status: EmotionStatus; // normal, concerning, crisis
|
||||
|
||||
// 敏感字段加密入 enc 字段
|
||||
note: string; // 打卡主观描述、情感文字
|
||||
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateEmotionDto {
|
||||
patientId: string;
|
||||
score: number;
|
||||
note: string;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Pool } from 'pg';
|
||||
import { EmotionRepository } from './emotion.repository';
|
||||
import { EmotionCheckin, EmotionStatus } from './emotion.types';
|
||||
import { FieldSealer } from '../../common/crypto/field-sealer';
|
||||
|
||||
interface SensitiveEmotion {
|
||||
note: string;
|
||||
}
|
||||
|
||||
interface EmotionRow {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
score: number;
|
||||
status: string;
|
||||
enc: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
const iso = (v: Date | string): string => (v instanceof Date ? v.toISOString() : String(v));
|
||||
|
||||
export class PostgresEmotionRepository extends EmotionRepository {
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly sealer: FieldSealer,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async save(e: EmotionCheckin): Promise<EmotionCheckin> {
|
||||
const sensitive: SensitiveEmotion = {
|
||||
note: e.note,
|
||||
};
|
||||
|
||||
await this.pool.query(
|
||||
`INSERT INTO emotions (id, patient_id, score, status, enc, created_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
score = EXCLUDED.score,
|
||||
status = EXCLUDED.status,
|
||||
enc = EXCLUDED.enc`,
|
||||
[
|
||||
e.id,
|
||||
e.patientId,
|
||||
e.score,
|
||||
e.status,
|
||||
this.sealer.seal(sensitive),
|
||||
e.createdAt,
|
||||
],
|
||||
);
|
||||
return e;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<EmotionCheckin | null> {
|
||||
const res = await this.pool.query<EmotionRow>('SELECT * FROM emotions WHERE id = $1', [id]);
|
||||
return res.rows[0] ? this.toDomain(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findByPatient(patientId: string): Promise<EmotionCheckin[]> {
|
||||
const res = await this.pool.query<EmotionRow>(
|
||||
'SELECT * FROM emotions WHERE patient_id = $1 ORDER BY created_at DESC',
|
||||
[patientId],
|
||||
);
|
||||
return res.rows.map((r) => this.toDomain(r));
|
||||
}
|
||||
|
||||
async findAll(): Promise<EmotionCheckin[]> {
|
||||
const res = await this.pool.query<EmotionRow>('SELECT * FROM emotions ORDER BY created_at DESC');
|
||||
return res.rows.map((r) => this.toDomain(r));
|
||||
}
|
||||
|
||||
private toDomain(row: EmotionRow): EmotionCheckin {
|
||||
const s = this.sealer.open<SensitiveEmotion>(row.enc);
|
||||
return {
|
||||
id: row.id,
|
||||
patientId: row.patient_id,
|
||||
score: Number(row.score),
|
||||
status: row.status as EmotionStatus,
|
||||
note: s.note,
|
||||
createdAt: iso(row.created_at),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { FollowupService } from './followup.service';
|
||||
import { FollowUp, TargetOperator } from './followup.types';
|
||||
import { RequireCaps } from '../../common/auth/capabilities.decorator';
|
||||
|
||||
@Controller()
|
||||
export class FollowupController {
|
||||
constructor(private readonly followup: FollowupService) {}
|
||||
|
||||
/** 列出某孕妇的全部跟进项(含到期状态刷新) */
|
||||
@Get('patients/:patientId/followups')
|
||||
@RequireCaps('disposition:read')
|
||||
async list(@Param('patientId') patientId: string): Promise<FollowUp[]> {
|
||||
const items = await this.followup.listByPatient(patientId);
|
||||
return this.followup.markDueStatuses(items);
|
||||
}
|
||||
|
||||
/** 手动为处置单创建跟进项(复测) */
|
||||
@Post('dispositions/:id/followups')
|
||||
@RequireCaps('disposition:execute')
|
||||
create(
|
||||
@Param('id') dispositionId: string,
|
||||
@Body()
|
||||
body: {
|
||||
patientId: string;
|
||||
indicator: string;
|
||||
targetOperator?: TargetOperator;
|
||||
targetValue?: number;
|
||||
windowDays?: number;
|
||||
},
|
||||
): Promise<FollowUp | null> {
|
||||
return this.followup.createForRecheck({ dispositionId, ...body });
|
||||
}
|
||||
|
||||
/** 手动评估跟进项(录入复测值,自动判定达标/未达标) */
|
||||
@Post('followups/:id/evaluate')
|
||||
@RequireCaps('disposition:execute')
|
||||
evaluate(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { value: number; observationId?: string },
|
||||
): Promise<FollowUp> {
|
||||
return this.followup.evaluate(id, body.value, body.observationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Pool } from 'pg';
|
||||
import { FollowupService } from './followup.service';
|
||||
import { FollowupController } from './followup.controller';
|
||||
import { FollowupRepository, InMemoryFollowupRepository } from './followup.repository';
|
||||
import { PostgresFollowupRepository } from './postgres-followup.repository';
|
||||
import { PG_POOL } from '../../common/db/db.tokens';
|
||||
import { createSealerFromEnv } from '../../common/crypto/field-sealer';
|
||||
|
||||
@Module({
|
||||
controllers: [FollowupController],
|
||||
providers: [
|
||||
FollowupService,
|
||||
{
|
||||
provide: FollowupRepository,
|
||||
useFactory: (pool: Pool | null): FollowupRepository =>
|
||||
pool
|
||||
? new PostgresFollowupRepository(pool, createSealerFromEnv())
|
||||
: new InMemoryFollowupRepository(),
|
||||
inject: [PG_POOL],
|
||||
},
|
||||
],
|
||||
exports: [FollowupService],
|
||||
})
|
||||
export class FollowupModule {}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { FollowUp } from './followup.types';
|
||||
|
||||
/** 跟进项仓储抽象(内存 + PG)。 */
|
||||
export abstract class FollowupRepository {
|
||||
abstract save(f: FollowUp): Promise<FollowUp>;
|
||||
abstract findById(id: string): Promise<FollowUp | null>;
|
||||
abstract findByPatient(patientId: string): Promise<FollowUp[]>;
|
||||
abstract findByDisposition(dispositionId: string): Promise<FollowUp[]>;
|
||||
/** 取某孕妇某指标下尚未评估的跟进项(复测回流自动比对用) */
|
||||
abstract findPendingByIndicator(patientId: string, indicator: string): Promise<FollowUp[]>;
|
||||
abstract findAll(): Promise<FollowUp[]>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InMemoryFollowupRepository extends FollowupRepository {
|
||||
private readonly items = new Map<string, FollowUp>();
|
||||
|
||||
async save(f: FollowUp): Promise<FollowUp> {
|
||||
this.items.set(f.id, f);
|
||||
return f;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<FollowUp | null> {
|
||||
return this.items.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findByPatient(patientId: string): Promise<FollowUp[]> {
|
||||
return [...this.items.values()]
|
||||
.filter((f) => f.patientId === patientId)
|
||||
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
}
|
||||
|
||||
async findByDisposition(dispositionId: string): Promise<FollowUp[]> {
|
||||
return [...this.items.values()].filter((f) => f.dispositionId === dispositionId);
|
||||
}
|
||||
|
||||
async findPendingByIndicator(patientId: string, indicator: string): Promise<FollowUp[]> {
|
||||
return [...this.items.values()].filter(
|
||||
(f) => f.patientId === patientId && f.indicator === indicator && f.status !== 'evaluated',
|
||||
);
|
||||
}
|
||||
|
||||
async findAll(): Promise<FollowUp[]> {
|
||||
return [...this.items.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { FollowupRepository } from './followup.repository';
|
||||
import {
|
||||
DEFAULT_TARGETS,
|
||||
FollowUp,
|
||||
IndicatorTarget,
|
||||
meetsTarget,
|
||||
TargetOperator,
|
||||
} from './followup.types';
|
||||
|
||||
export interface CreateFollowUpInput {
|
||||
dispositionId: string;
|
||||
patientId: string;
|
||||
indicator: string;
|
||||
/** 覆盖默认目标(缺省用 DEFAULT_TARGETS[indicator]) */
|
||||
targetOperator?: TargetOperator;
|
||||
targetValue?: number;
|
||||
windowDays?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 跟进项服务(REQ-D2)。
|
||||
* 复测回流后按目标自动比对达标/未达标,形成"行动→反馈"闭环。
|
||||
*/
|
||||
@Injectable()
|
||||
export class FollowupService {
|
||||
private readonly logger = new Logger('Followup');
|
||||
|
||||
constructor(private readonly repo: FollowupRepository) {}
|
||||
|
||||
/**
|
||||
* 为复测创建跟进项;目标缺省取指标默认值。
|
||||
* 若既无显式目标也无默认(如尚不支持的指标),返回 null(不阻断处置执行)。
|
||||
*/
|
||||
async createForRecheck(input: CreateFollowUpInput): Promise<FollowUp | null> {
|
||||
const fallback: IndicatorTarget | undefined = DEFAULT_TARGETS[input.indicator];
|
||||
const operator = input.targetOperator ?? fallback?.operator;
|
||||
const value = input.targetValue ?? fallback?.value;
|
||||
const windowDays = input.windowDays ?? fallback?.windowDays;
|
||||
if (operator === undefined || value === undefined || windowDays === undefined) {
|
||||
this.logger.warn(`指标 ${input.indicator} 无默认达标目标,跳过跟进项创建`);
|
||||
return null;
|
||||
}
|
||||
const now = new Date();
|
||||
const due = new Date(now.getTime() + windowDays * 24 * 60 * 60 * 1000);
|
||||
const f: FollowUp = {
|
||||
id: randomUUID(),
|
||||
dispositionId: input.dispositionId,
|
||||
patientId: input.patientId,
|
||||
indicator: input.indicator,
|
||||
targetOperator: operator,
|
||||
targetValue: value,
|
||||
windowDays,
|
||||
dueAt: due.toISOString(),
|
||||
status: 'pending',
|
||||
outcome: null,
|
||||
evaluatedObservationId: null,
|
||||
evaluatedAt: null,
|
||||
createdAt: now.toISOString(),
|
||||
};
|
||||
return this.repo.save(f);
|
||||
}
|
||||
|
||||
listByPatient(patientId: string): Promise<FollowUp[]> {
|
||||
return this.repo.findByPatient(patientId);
|
||||
}
|
||||
|
||||
listByDisposition(dispositionId: string): Promise<FollowUp[]> {
|
||||
return this.repo.findByDisposition(dispositionId);
|
||||
}
|
||||
|
||||
listAll(): Promise<FollowUp[]> {
|
||||
return this.repo.findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 复测数据回流时自动评估:取该孕妇该指标下未评估的跟进项,按目标判定达标/未达标。
|
||||
* 返回被评估的跟进项(供 worklist/通知)。
|
||||
*/
|
||||
async evaluateOnObservation(
|
||||
patientId: string,
|
||||
indicator: string,
|
||||
value: number,
|
||||
observationId: string,
|
||||
): Promise<FollowUp[]> {
|
||||
const pending = await this.repo.findPendingByIndicator(patientId, indicator);
|
||||
const evaluated: FollowUp[] = [];
|
||||
for (const f of pending) {
|
||||
f.outcome = meetsTarget(value, f.targetOperator, f.targetValue) ? 'met' : 'not_met';
|
||||
f.status = 'evaluated';
|
||||
f.evaluatedObservationId = observationId;
|
||||
f.evaluatedAt = new Date().toISOString();
|
||||
await this.repo.save(f);
|
||||
evaluated.push(f);
|
||||
}
|
||||
return evaluated;
|
||||
}
|
||||
|
||||
/** 手动评估某跟进项(医护录入复测值) */
|
||||
async evaluate(id: string, value: number, observationId?: string): Promise<FollowUp> {
|
||||
const f = await this.repo.findById(id);
|
||||
if (!f) throw new NotFoundException('跟进项不存在');
|
||||
f.outcome = meetsTarget(value, f.targetOperator, f.targetValue) ? 'met' : 'not_met';
|
||||
f.status = 'evaluated';
|
||||
f.evaluatedObservationId = observationId ?? null;
|
||||
f.evaluatedAt = new Date().toISOString();
|
||||
return this.repo.save(f);
|
||||
}
|
||||
|
||||
/** 标记到期(pending 且已过 dueAt → due);返回刷新后的列表。 */
|
||||
markDueStatuses(followups: FollowUp[], now = new Date()): FollowUp[] {
|
||||
return followups.map((f) => {
|
||||
if (f.status === 'pending' && new Date(f.dueAt).getTime() <= now.getTime()) {
|
||||
return { ...f, status: 'due' as const };
|
||||
}
|
||||
return f;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 跟进项与达标判定(REQ-D2,详见 6-exec-PCM.md §4.4)。
|
||||
* 处置含复测时生成跟进项,复测数据回流后按目标自动比对达标/未达标。
|
||||
*/
|
||||
|
||||
export type TargetOperator = '<' | '<=' | '>' | '>=' | '==';
|
||||
export type FollowUpStatus = 'pending' | 'due' | 'evaluated';
|
||||
export type FollowUpOutcome = 'met' | 'not_met';
|
||||
|
||||
export interface FollowUp {
|
||||
id: string;
|
||||
dispositionId: string;
|
||||
patientId: string;
|
||||
indicator: string;
|
||||
/** 达标判定:value <op> targetValue 视为达标 */
|
||||
targetOperator: TargetOperator;
|
||||
targetValue: number;
|
||||
windowDays: number;
|
||||
dueAt: string;
|
||||
status: FollowUpStatus;
|
||||
outcome?: FollowUpOutcome | null;
|
||||
evaluatedObservationId?: string | null;
|
||||
evaluatedAt?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface IndicatorTarget {
|
||||
operator: TargetOperator;
|
||||
value: number;
|
||||
windowDays: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用默认达标目标与复测时间窗(6-exec §4.4 / 7-indicator §4.4)。
|
||||
* 通用占位,须医生确认并可按个体覆盖;非诊断标准。
|
||||
*/
|
||||
export const DEFAULT_TARGETS: Record<string, IndicatorTarget> = {
|
||||
fasting_glucose: { operator: '<', value: 5.1, windowDays: 7 },
|
||||
postprandial_glucose: { operator: '<', value: 6.7, windowDays: 7 },
|
||||
ogtt_2h: { operator: '<', value: 6.7, windowDays: 7 },
|
||||
systolic_bp: { operator: '<', value: 140, windowDays: 3 },
|
||||
diastolic_bp: { operator: '<', value: 90, windowDays: 3 },
|
||||
};
|
||||
|
||||
/** 按运算符比较,返回是否达标。 */
|
||||
export function meetsTarget(value: number, operator: TargetOperator, target: number): boolean {
|
||||
switch (operator) {
|
||||
case '<':
|
||||
return value < target;
|
||||
case '<=':
|
||||
return value <= target;
|
||||
case '>':
|
||||
return value > target;
|
||||
case '>=':
|
||||
return value >= target;
|
||||
case '==':
|
||||
return value === target;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Pool } from 'pg';
|
||||
import { FollowupRepository } from './followup.repository';
|
||||
import { FollowUp, FollowUpOutcome, FollowUpStatus, TargetOperator } from './followup.types';
|
||||
import { FieldSealer } from '../../common/crypto/field-sealer';
|
||||
|
||||
interface SensitiveFollowUp {
|
||||
targetOperator: TargetOperator;
|
||||
targetValue: number;
|
||||
windowDays: number;
|
||||
evaluatedObservationId: string | null;
|
||||
}
|
||||
|
||||
interface FollowUpRow {
|
||||
id: string;
|
||||
disposition_id: string;
|
||||
patient_id: string;
|
||||
indicator: string;
|
||||
status: string;
|
||||
outcome: string | null;
|
||||
enc: string;
|
||||
due_at: Date;
|
||||
created_at: Date;
|
||||
evaluated_at: Date | null;
|
||||
}
|
||||
|
||||
const iso = (v: Date | string): string => (v instanceof Date ? v.toISOString() : String(v));
|
||||
const isoOrNull = (v: Date | string | null): string | null => (v == null ? null : iso(v));
|
||||
|
||||
/** PostgreSQL 跟进项仓储(T-D.2)。目标值/复测观测ID 加密入 enc;indicator/status/due 留列供匹配。 */
|
||||
export class PostgresFollowupRepository extends FollowupRepository {
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly sealer: FieldSealer,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async save(f: FollowUp): Promise<FollowUp> {
|
||||
const sensitive: SensitiveFollowUp = {
|
||||
targetOperator: f.targetOperator,
|
||||
targetValue: f.targetValue,
|
||||
windowDays: f.windowDays,
|
||||
evaluatedObservationId: f.evaluatedObservationId ?? null,
|
||||
};
|
||||
await this.pool.query(
|
||||
`INSERT INTO followups (id, disposition_id, patient_id, indicator, status, outcome, enc, due_at, created_at, evaluated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
status = EXCLUDED.status, outcome = EXCLUDED.outcome,
|
||||
enc = EXCLUDED.enc, evaluated_at = EXCLUDED.evaluated_at`,
|
||||
[
|
||||
f.id,
|
||||
f.dispositionId,
|
||||
f.patientId,
|
||||
f.indicator,
|
||||
f.status,
|
||||
f.outcome ?? null,
|
||||
this.sealer.seal(sensitive),
|
||||
f.dueAt,
|
||||
f.createdAt,
|
||||
f.evaluatedAt ?? null,
|
||||
],
|
||||
);
|
||||
return f;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<FollowUp | null> {
|
||||
const res = await this.pool.query<FollowUpRow>('SELECT * FROM followups WHERE id = $1', [id]);
|
||||
return res.rows[0] ? this.toDomain(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findByPatient(patientId: string): Promise<FollowUp[]> {
|
||||
const res = await this.pool.query<FollowUpRow>(
|
||||
'SELECT * FROM followups WHERE patient_id = $1 ORDER BY created_at',
|
||||
[patientId],
|
||||
);
|
||||
return res.rows.map((r) => this.toDomain(r));
|
||||
}
|
||||
|
||||
async findByDisposition(dispositionId: string): Promise<FollowUp[]> {
|
||||
const res = await this.pool.query<FollowUpRow>(
|
||||
'SELECT * FROM followups WHERE disposition_id = $1 ORDER BY created_at',
|
||||
[dispositionId],
|
||||
);
|
||||
return res.rows.map((r) => this.toDomain(r));
|
||||
}
|
||||
|
||||
async findAll(): Promise<FollowUp[]> {
|
||||
const res = await this.pool.query<FollowUpRow>('SELECT * FROM followups ORDER BY created_at DESC');
|
||||
return res.rows.map((r) => this.toDomain(r));
|
||||
}
|
||||
|
||||
async findPendingByIndicator(patientId: string, indicator: string): Promise<FollowUp[]> {
|
||||
const res = await this.pool.query<FollowUpRow>(
|
||||
`SELECT * FROM followups WHERE patient_id = $1 AND indicator = $2 AND status <> 'evaluated' ORDER BY created_at`,
|
||||
[patientId, indicator],
|
||||
);
|
||||
return res.rows.map((r) => this.toDomain(r));
|
||||
}
|
||||
|
||||
private toDomain(row: FollowUpRow): FollowUp {
|
||||
const s = this.sealer.open<SensitiveFollowUp>(row.enc);
|
||||
return {
|
||||
id: row.id,
|
||||
dispositionId: row.disposition_id,
|
||||
patientId: row.patient_id,
|
||||
indicator: row.indicator,
|
||||
targetOperator: s.targetOperator,
|
||||
targetValue: s.targetValue,
|
||||
windowDays: s.windowDays,
|
||||
dueAt: iso(row.due_at),
|
||||
status: row.status as FollowUpStatus,
|
||||
outcome: (row.outcome as FollowUpOutcome | null) ?? null,
|
||||
evaluatedObservationId: s.evaluatedObservationId,
|
||||
evaluatedAt: isoOrNull(row.evaluated_at),
|
||||
createdAt: iso(row.created_at),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { HealthService, HealthStatus } from './health.service';
|
||||
import { Public } from '../../common/auth/public.decorator';
|
||||
|
||||
@Public()
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(private readonly healthService: HealthService) {}
|
||||
|
||||
@Get()
|
||||
check(): HealthStatus {
|
||||
return this.healthService.check();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
providers: [HealthService],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
describe('HealthService', () => {
|
||||
let service: HealthService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new HealthService();
|
||||
});
|
||||
|
||||
it('returns ok status with service metadata', () => {
|
||||
const result = service.check();
|
||||
expect(result.status).toBe('ok');
|
||||
expect(result.service).toBe('pcm-backend');
|
||||
expect(result.version).toBe('0.1.0');
|
||||
});
|
||||
|
||||
it('returns a valid ISO timestamp', () => {
|
||||
const result = service.check();
|
||||
expect(Number.isNaN(Date.parse(result.timestamp))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
export interface HealthStatus {
|
||||
status: 'ok';
|
||||
service: string;
|
||||
version: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HealthService {
|
||||
check(): HealthStatus {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'pcm-backend',
|
||||
version: '0.1.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 知识检索打分(占位,生产用向量相似度)。纯函数,供内存与 PG 仓储共用,保证行为一致。
|
||||
* 规则:问题包含某关键词 +2;问题包含标题 +3。返回命中且按分排序的前 limit 条。
|
||||
*/
|
||||
interface Rankable {
|
||||
title: string;
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
export function rankKnowledge<T extends Rankable>(items: T[], query: string, limit: number): T[] {
|
||||
const q = query.toLowerCase();
|
||||
return items
|
||||
.map((item) => ({ item, score: scoreItem(item, q) }))
|
||||
.filter((x) => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map((x) => x.item);
|
||||
}
|
||||
|
||||
function scoreItem(item: Rankable, q: string): number {
|
||||
let score = 0;
|
||||
for (const kw of item.keywords) {
|
||||
if (q.includes(kw.toLowerCase())) score += 2;
|
||||
}
|
||||
if (q && item.title.toLowerCase().includes(q)) score += 3;
|
||||
return score;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
|
||||
import { CreateKnowledgeInput, KnowledgeService } from './knowledge.service';
|
||||
import { KnowledgeItem, QaAnswer } from './knowledge.types';
|
||||
import { RequireCaps } from '../../common/auth/capabilities.decorator';
|
||||
|
||||
@Controller('knowledge')
|
||||
export class KnowledgeController {
|
||||
constructor(private readonly knowledge: KnowledgeService) {}
|
||||
|
||||
/** 录入知识条目(REQ-7.1) */
|
||||
@Post()
|
||||
@RequireCaps('knowledge:write')
|
||||
create(@Body() body: CreateKnowledgeInput): Promise<KnowledgeItem> {
|
||||
return this.knowledge.create(body);
|
||||
}
|
||||
|
||||
/** 列出/检索知识条目(运营端知识管理 T-8.4) */
|
||||
@Get()
|
||||
@RequireCaps('knowledge:write')
|
||||
list(
|
||||
@Query('q') q?: string,
|
||||
@Query('category') category?: KnowledgeItem['category'],
|
||||
): Promise<KnowledgeItem[]> {
|
||||
return this.knowledge.list({ q, category });
|
||||
}
|
||||
|
||||
/** 聊天式问答(REQ-7.2,孕妇端主入口的后端能力) */
|
||||
@Get('ask')
|
||||
@RequireCaps('knowledge:ask')
|
||||
ask(@Query('q') q: string): Promise<QaAnswer> {
|
||||
return this.knowledge.ask(q);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Pool } from 'pg';
|
||||
import { KnowledgeService } from './knowledge.service';
|
||||
import { KnowledgeController } from './knowledge.controller';
|
||||
import { InMemoryKnowledgeRepository, KnowledgeRepository } from './knowledge.repository';
|
||||
import { PostgresKnowledgeRepository } from './postgres-knowledge.repository';
|
||||
import { PG_POOL } from '../../common/db/db.tokens';
|
||||
|
||||
@Module({
|
||||
controllers: [KnowledgeController],
|
||||
providers: [
|
||||
KnowledgeService,
|
||||
{
|
||||
provide: KnowledgeRepository,
|
||||
useFactory: (pool: Pool | null): KnowledgeRepository =>
|
||||
pool ? new PostgresKnowledgeRepository(pool) : new InMemoryKnowledgeRepository(),
|
||||
inject: [PG_POOL],
|
||||
},
|
||||
],
|
||||
exports: [KnowledgeService],
|
||||
})
|
||||
export class KnowledgeModule {}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { KnowledgeItem } from './knowledge.types';
|
||||
import { rankKnowledge } from './knowledge-search';
|
||||
|
||||
/**
|
||||
* 知识库仓储抽象。
|
||||
* 内存实现含简单关键词检索;生产替换为向量库 + 全文检索(RAG)。
|
||||
*/
|
||||
export abstract class KnowledgeRepository {
|
||||
abstract save(item: KnowledgeItem): Promise<KnowledgeItem>;
|
||||
abstract all(): Promise<KnowledgeItem[]>;
|
||||
abstract search(query: string, limit: number): Promise<KnowledgeItem[]>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InMemoryKnowledgeRepository extends KnowledgeRepository {
|
||||
private readonly items: KnowledgeItem[] = [];
|
||||
|
||||
async save(item: KnowledgeItem): Promise<KnowledgeItem> {
|
||||
this.items.push(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
async all(): Promise<KnowledgeItem[]> {
|
||||
return [...this.items];
|
||||
}
|
||||
|
||||
async search(query: string, limit: number): Promise<KnowledgeItem[]> {
|
||||
return rankKnowledge(this.items, query, limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { KnowledgeService } from './knowledge.service';
|
||||
import { InMemoryKnowledgeRepository } from './knowledge.repository';
|
||||
|
||||
describe('KnowledgeService(知识库 + RAG 问答)', () => {
|
||||
let service: KnowledgeService;
|
||||
|
||||
beforeEach(async () => {
|
||||
service = new KnowledgeService(new InMemoryKnowledgeRepository());
|
||||
await service.create({
|
||||
category: 'guideline',
|
||||
title: '妊娠期糖尿病饮食管理',
|
||||
content: '控制碳水、少食多餐、餐后适度活动有助于血糖控制。',
|
||||
keywords: ['血糖', '糖尿病', '饮食', 'gdm'],
|
||||
source: '某权威指南',
|
||||
authority: 'authoritative',
|
||||
});
|
||||
});
|
||||
|
||||
it('录入知识需标注来源', async () => {
|
||||
await expect(
|
||||
service.create({ category: 'tcm', title: 't', content: 'c', source: '' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('命中知识 → grounded 且带溯源', async () => {
|
||||
const ans = await service.ask('血糖高怎么饮食');
|
||||
expect(ans.grounded).toBe(true);
|
||||
expect(ans.citations.length).toBeGreaterThan(0);
|
||||
expect(ans.citations[0].source).toBe('某权威指南');
|
||||
});
|
||||
|
||||
it('无依据 → 不超纲,明确告知', async () => {
|
||||
const ans = await service.ask('明天天气怎么样');
|
||||
expect(ans.grounded).toBe(false);
|
||||
expect(ans.citations).toHaveLength(0);
|
||||
expect(ans.answer).toContain('没有找到');
|
||||
});
|
||||
|
||||
it('空问题 → 抛错', async () => {
|
||||
await expect(service.ask(' ')).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('权威级别更高的条目优先', async () => {
|
||||
await service.create({
|
||||
category: 'guideline',
|
||||
title: '血糖小贴士',
|
||||
content: '自有内容',
|
||||
keywords: ['血糖'],
|
||||
source: '自有',
|
||||
authority: 'self',
|
||||
});
|
||||
const ans = await service.ask('血糖');
|
||||
expect(ans.citations[0].authority).toBe('authoritative');
|
||||
});
|
||||
|
||||
describe('list(运营端知识管理)', () => {
|
||||
it('默认返回全部,按创建时间倒序', async () => {
|
||||
await service.create({
|
||||
category: 'tcm',
|
||||
title: '孕期调养',
|
||||
content: '温和调养内容',
|
||||
keywords: ['调养'],
|
||||
source: '某来源',
|
||||
});
|
||||
const items = await service.list();
|
||||
expect(items.length).toBe(2);
|
||||
// 最新创建的在前
|
||||
expect(items[0].title).toBe('孕期调养');
|
||||
});
|
||||
|
||||
it('按分类过滤', async () => {
|
||||
await service.create({
|
||||
category: 'tcm',
|
||||
title: '孕期调养',
|
||||
content: '温和调养内容',
|
||||
keywords: ['调养'],
|
||||
source: '某来源',
|
||||
});
|
||||
const tcm = await service.list({ category: 'tcm' });
|
||||
expect(tcm).toHaveLength(1);
|
||||
expect(tcm[0].category).toBe('tcm');
|
||||
});
|
||||
|
||||
it('按关键词子串检索(标题/内容/关键词)', async () => {
|
||||
const hits = await service.list({ q: '糖尿病' });
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0].title).toContain('糖尿病');
|
||||
const none = await service.list({ q: '不存在的词xyz' });
|
||||
expect(none).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { KnowledgeRepository } from './knowledge.repository';
|
||||
import { KnowledgeCitation, KnowledgeItem, QaAnswer } from './knowledge.types';
|
||||
|
||||
export interface CreateKnowledgeInput {
|
||||
category: KnowledgeItem['category'];
|
||||
title: string;
|
||||
content: string;
|
||||
keywords?: string[];
|
||||
source: string;
|
||||
authority?: KnowledgeItem['authority'];
|
||||
}
|
||||
|
||||
const NO_ANSWER =
|
||||
'抱歉,我在权威知识库中没有找到可靠依据,建议咨询您的个案管理师或医生。';
|
||||
|
||||
/**
|
||||
* 知识库与 RAG 问答服务(REQ-7)。
|
||||
* 关键约束(REQ-7.3 / 附录四 5.1):
|
||||
* - 仅基于检索到的知识条目作答(检索增强);
|
||||
* - 无依据时明确告知,禁止超纲生成;
|
||||
* - 答案必须附溯源(citations)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class KnowledgeService {
|
||||
constructor(private readonly repo: KnowledgeRepository) {}
|
||||
|
||||
async create(input: CreateKnowledgeInput): Promise<KnowledgeItem> {
|
||||
if (!input.title?.trim() || !input.content?.trim()) {
|
||||
throw new BadRequestException('标题与内容不能为空');
|
||||
}
|
||||
if (!input.source?.trim()) {
|
||||
throw new BadRequestException('知识条目必须标注来源');
|
||||
}
|
||||
const item: KnowledgeItem = {
|
||||
id: randomUUID(),
|
||||
category: input.category,
|
||||
title: input.title.trim(),
|
||||
content: input.content.trim(),
|
||||
keywords: input.keywords ?? [],
|
||||
source: input.source.trim(),
|
||||
authority: input.authority ?? 'reference',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
return this.repo.save(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出知识条目(运营/管理端知识管理用 T-8.4)。
|
||||
* 支持按分类过滤与关键词子串匹配(标题/内容/关键词);按创建时间倒序。
|
||||
*/
|
||||
async list(filter?: { q?: string; category?: KnowledgeItem['category'] }): Promise<KnowledgeItem[]> {
|
||||
const all = await this.repo.all();
|
||||
const q = filter?.q?.trim().toLowerCase();
|
||||
return all
|
||||
.filter((item) => (filter?.category ? item.category === filter.category : true))
|
||||
.filter((item) => {
|
||||
if (!q) return true;
|
||||
const haystack = [item.title, item.content, ...item.keywords].join(' ').toLowerCase();
|
||||
return haystack.includes(q);
|
||||
})
|
||||
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
||||
}
|
||||
|
||||
/**
|
||||
* 问答(RAG)。检索 → 若有依据则基于条目组织答案并附溯源;否则明确告知无法回答。
|
||||
* 注:此处用"检索内容拼接 + 溯源"作为可解释的占位实现;
|
||||
* 生产接入大模型时,prompt 必须约束"只能依据下列条目作答、不得超纲"。
|
||||
*/
|
||||
async ask(question: string): Promise<QaAnswer> {
|
||||
const q = (question ?? '').trim();
|
||||
if (!q) {
|
||||
throw new BadRequestException('问题不能为空');
|
||||
}
|
||||
|
||||
const hits = await this.repo.search(q, 3);
|
||||
if (hits.length === 0) {
|
||||
return { grounded: false, answer: NO_ANSWER, citations: [] };
|
||||
}
|
||||
|
||||
// 冲突时按权威级别优先(authoritative > reference > self)
|
||||
const ordered = [...hits].sort(
|
||||
(a, b) => this.authorityRank(b.authority) - this.authorityRank(a.authority),
|
||||
);
|
||||
|
||||
const answer = ordered
|
||||
.map((h) => `【${h.title}】${h.content}`)
|
||||
.join('\n');
|
||||
const citations: KnowledgeCitation[] = ordered.map((h) => ({
|
||||
id: h.id,
|
||||
title: h.title,
|
||||
source: h.source,
|
||||
authority: h.authority,
|
||||
}));
|
||||
|
||||
return { grounded: true, answer, citations };
|
||||
}
|
||||
|
||||
private authorityRank(a: KnowledgeItem['authority']): number {
|
||||
return a === 'authoritative' ? 3 : a === 'reference' ? 2 : 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export type KnowledgeCategory =
|
||||
| 'guideline' // 临床指南
|
||||
| 'indicator_reference' // 指标释义
|
||||
| 'intervention' // 干预知识
|
||||
| 'tcm'; // 中医调养
|
||||
|
||||
export type AuthorityLevel = 'authoritative' | 'reference' | 'self';
|
||||
|
||||
/** 知识条目(REQ-7.1)。每条须标注来源与权威级别,支持溯源。 */
|
||||
export interface KnowledgeItem {
|
||||
id: string;
|
||||
category: KnowledgeCategory;
|
||||
title: string;
|
||||
content: string;
|
||||
/** 检索关键词 */
|
||||
keywords: string[];
|
||||
source: string;
|
||||
authority: AuthorityLevel;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** 问答引用(溯源) */
|
||||
export interface KnowledgeCitation {
|
||||
id: string;
|
||||
title: string;
|
||||
source: string;
|
||||
authority: AuthorityLevel;
|
||||
}
|
||||
|
||||
/** 问答结果(REQ-7.2/7.3) */
|
||||
export interface QaAnswer {
|
||||
/** 是否有知识库依据 */
|
||||
grounded: boolean;
|
||||
answer: string;
|
||||
citations: KnowledgeCitation[];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user