Initial commit: HealthCarePregnant project documentation and platform

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
selfrelease
2026-06-18 09:48:05 +08:00
commit eab91174db
301 changed files with 42491 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
coverage
.env
.env.local
*.log
.git
.DS_Store
Dockerfile
.dockerignore
+21
View File
@@ -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-GCM32 字节;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,不得提交仓库
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
*.log
.env
.env.local
coverage/
.DS_Store
+25
View File
@@ -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"]
+7
View File
@@ -0,0 +1,7 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
File diff suppressed because it is too large Load Diff
+55
View File
@@ -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"
}
}
+61
View File
@@ -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:未配置返回 nullhex64 解析为 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('未配置密钥 → 明文 JSONdev),可还原', () => {
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/规则/说明等敏感内容加密入 enclevel/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);`);
// 照护计划:目标/干预等临床内容加密入 enccase/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']);
});
});
});
+13
View File
@@ -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('generateRecommendationAI 决策建议)', () => {
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)/aiT-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(持久化);
* - 否则 → InMemoryAuthRepositorydev/测试)。
*/
@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 动作执行后自动生成跟进项并回填 linkedEntityIdREQ-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;
/** 落地后生成的实体 IDreminderId/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 加密入 encindicator/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