外包风险评估系统:领域引擎+前端+服务端持久化与生产部署

- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解
- 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护)
- 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理
- 专业图标体系替换全部emoji、看板美化
- 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC
- 444单测+204前端测试+51 e2e
This commit is contained in:
freedakgmail
2026-06-13 01:06:39 +08:00
commit c670b9e454
404 changed files with 61820 additions and 0 deletions
@@ -0,0 +1,141 @@
/**
* Assessor 可执行评估 的单元测试(RBACReq 12.2)。
*
* 验收标准 12.2WHERE 操作者角色为 AssessorTHE System SHALL 允许使用当前 Risk_Model 执行评估。
*
* 设计要点:配置访问控制({@link requireRole} / {@link canModifyConfig} / {@link applyConfigChange}
* 仅守卫**配置修改**写操作(唯 Administrator 可写,Req 12.1);**执行评估**(如 Scoring_Engine
* 的 {@link computeRiskScore})是纯计算读路径,不经任何角色守卫,因此 Assessor 可照常使用当前
* Risk_Model 完成评估。
*
* 本测试以示例验证该"允许路径":
* - Assessor 调用 `computeRiskScore` 能正常得到合法 Risk_Score(评估不被角色拦截);
* - 对照组:同一 Assessor 的配置修改请求被拒绝且配置不变(Req 12.1),佐证"可用不可改"。
*
* Feature: outsourcing-risk-assessment
* Validates: Requirements 12.2
*/
import { describe, expect, it } from 'vitest';
import {
RISK_LEVEL_VALUES,
type RiskLevel,
} from '../../domain/common.js';
import type { Dimension, Indicator, RiskModel, ScoringRule } from '../../domain/model.js';
import { computeRiskScore } from '../../scoring/computeRiskScore.js';
import type { RiskLevelResolver } from '../../scoring/scoringEngine.js';
import { InMemoryConfigAuditStore } from '../auditStore.js';
import { applyConfigChange } from '../configChange.js';
import { canModifyConfig } from '../requireRole.js';
import type { Actor } from '../roles.js';
// ----------------------------------------------------------------------------
// 测试夹具
// ----------------------------------------------------------------------------
/** 合法 Indicator 须覆盖 Risk_Level 1-5Req 11.3);评分本身不读取规则文本。 */
const scoringRules: ScoringRule[] = (RISK_LEVEL_VALUES as readonly RiskLevel[]).map(
(level) => ({ level, label: `L${level}`, description: `规则${level}` }),
);
function makeIndicator(id: string, weight: number): Indicator {
return {
id,
name: `指标 ${id}`,
weight,
enabled: true,
scoringRules,
evidenceRequired: '',
askPrompt: '',
};
}
/** 一个最小但可计分的当前 Risk_Model(单维度、双启用指标)。 */
function makeCurrentRiskModel(): RiskModel {
const dimension: Dimension = {
id: 'd-customer',
name: '客户风险',
weight: 100,
enabled: true,
indicators: [makeIndicator('d-customer-i1', 60), makeIndicator('d-customer-i2', 40)],
};
return {
id: 'm-current',
name: '当前风险模型',
businessType: '岗位外包',
dimensions: [dimension],
redlines: [],
};
}
/** 评估者(Assessor)操作者。 */
const assessor: Actor = { id: 'user-assessor', role: 'Assessor' };
// ----------------------------------------------------------------------------
// 单元测试
// ----------------------------------------------------------------------------
describe('Assessor 可执行评估(Req 12.2', () => {
it('Assessor 可使用当前 Risk_Model 执行评估并得到合法 Risk_Score', () => {
const model = makeCurrentRiskModel();
// 解析器返回各指标本次评估的 Risk_Level(评估取值来源,与角色无关)。
const resolveRiskLevel: RiskLevelResolver = () => 3;
// 评估为纯计算读路径,不经任何角色守卫:Assessor 身份不会阻断评估。
const score = computeRiskScore(model, resolveRiskLevel);
expect(Number.isInteger(score)).toBe(true);
expect(score).toBeGreaterThanOrEqual(0);
expect(score).toBeLessThanOrEqual(100);
// 全部指标 Risk_Level=3 → 线性映射 round((3-1)/4*100) = 50。
expect(score).toBe(50);
});
it('评估端点行为与角色无关:Assessor 执行评估同样满足 0/100 端点', () => {
const model = makeCurrentRiskModel();
expect(computeRiskScore(model, () => 1)).toBe(0);
expect(computeRiskScore(model, () => 5)).toBe(100);
});
it('对照组:Assessor 不可修改配置(可用不可改,Req 12.1 / 12.2', () => {
// canModifyConfig 仅 Administrator 为真;Assessor 为假。
expect(canModifyConfig(assessor)).toBe(false);
const auditStore = new InMemoryConfigAuditStore();
const currentConfig = { threshold: 10 };
const result = applyConfigChange(
{
actor: assessor,
currentConfig,
changedConfigKeys: ['threshold'],
apply: (current) => ({ ...current, threshold: 999 }),
},
auditStore,
);
// 配置修改被拒绝、配置保持不变(Req 12.1)。
expect(result.status).toBe('rejected');
expect(result.config).toBe(currentConfig);
expect(result.config.threshold).toBe(10);
});
it('同一 Assessor:配置修改被拒绝,但执行评估仍可正常进行', () => {
const auditStore = new InMemoryConfigAuditStore();
const modifyResult = applyConfigChange(
{
actor: assessor,
currentConfig: { enabled: true },
changedConfigKeys: ['enabled'],
apply: (current) => ({ ...current, enabled: false }),
},
auditStore,
);
expect(modifyResult.status).toBe('rejected');
// 修改被拒后,Assessor 依旧能使用当前 Risk_Model 执行评估(Req 12.2)。
const model = makeCurrentRiskModel();
const score = computeRiskScore(model, () => 4);
expect(score).toBe(75);
});
});
@@ -0,0 +1,161 @@
/**
* Property 46: 配置变更与拒绝均留痕审计 的属性化测试(RBAC,Req 12.4, 12.5)。
*
* 属性陈述:
* - 对任意成功提交的配置变更,审计日志必记录操作者身份、精确到秒的变更时间,
* 以及发生变更的配置项标识(Req 12.4)。
* - 对任意被拒绝的配置修改请求,审计日志必记录操作者身份与精确到秒的请求时间(Req 12.5)。
*
* 本测试以智能生成器构造任意操作者、任意当前配置、任意变更项标识与任意请求时刻:
* - 操作者身份 id 取遍非空字符串;提交分支固定 Administrator 角色,
* 拒绝分支取遍非 Administrator 角色(Assessor / 未认证 / 未授权);
* - 通过注入确定性时钟覆盖任意(合法范围内)请求时刻,验证时间戳精确到秒;
* - 变更项标识取遍 0..N 个任意字符串,验证提交分支原样留痕。
*
* 对每次调用均断言审计条目已写入存储,且承载所要求的留痕字段。
*
* Feature: outsourcing-risk-assessment, Property 46: 配置变更与拒绝均留痕审计
* Validates: Requirements 12.4, 12.5
*/
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import {
applyConfigChange,
InMemoryConfigAuditStore,
toSecondPrecisionIso,
type Actor,
type AuthRole,
type ConfigChangeRequest,
} from '../index.js';
// ----------------------------------------------------------------------------
// 生成器
// ----------------------------------------------------------------------------
/** 被测配置类型:与具体形态无关,以字符串->数值的记录表征。 */
type TestConfig = Record<string, number>;
/** 操作者身份标识:非空字符串。 */
const actorIdArb: fc.Arbitrary<string> = fc.string({ minLength: 1, maxLength: 24 });
/** 非 Administrator 角色(拒绝分支)。 */
const nonAdminRoleArb: fc.Arbitrary<AuthRole> = fc.constantFrom<AuthRole>(
'Assessor',
'未认证',
'未授权',
);
/** 当前配置生成器。 */
const configArb: fc.Arbitrary<TestConfig> = fc.dictionary(
fc.string({ minLength: 1, maxLength: 8 }),
fc.double({ noNaN: true, noDefaultInfinity: true }),
{ maxKeys: 6 },
);
/** 变更项标识列表(可为空)。 */
const changedKeysArb: fc.Arbitrary<string[]> = fc.array(
fc.string({ minLength: 1, maxLength: 12 }),
{ minLength: 0, maxLength: 8 },
);
/**
* 请求时刻生成器:限定在合法可格式化范围内,覆盖任意整毫秒时刻,
* 用以验证 toSecondPrecisionIso 的整秒截断。
*/
const requestTimeArb: fc.Arbitrary<Date> = fc.date({
min: new Date('1970-01-01T00:00:00.000Z'),
max: new Date('2999-12-31T23:59:59.999Z'),
});
/** 精确到秒的 ISO 8601 形态判定(毫秒部分固定为 000)。 */
const SECOND_PRECISION_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.000Z$/;
describe('Property 46: 配置变更与拒绝均留痕审计 (Req 12.4, 12.5)', () => {
it('成功提交:审计记录操作者身份、精确到秒变更时间与变更项标识 (Req 12.4)', () => {
fc.assert(
fc.property(
actorIdArb,
configArb,
changedKeysArb,
requestTimeArb,
(actorId, currentConfig, changedConfigKeys, requestTime) => {
const store = new InMemoryConfigAuditStore();
const actor: Actor = { id: actorId, role: 'Administrator' };
const request: ConfigChangeRequest<TestConfig> = {
actor,
currentConfig,
changedConfigKeys,
apply: (current) => ({ ...current }),
};
const result = applyConfigChange(request, store, {
clock: () => requestTime,
});
// 必为提交分支。
expect(result.status).toBe('committed');
// 审计条目已写入存储(恰一条)。
const entries = store.getAll();
expect(entries).toHaveLength(1);
const entry = entries[0]!;
expect(entry).toEqual(result.auditEntry);
// 留痕字段:操作者身份、动作、精确到秒变更时间、变更项标识。
expect(entry.action).toBe('变更提交');
expect(entry.actorId).toBe(actorId);
expect(entry.timestamp).toBe(toSecondPrecisionIso(requestTime));
expect(entry.timestamp).toMatch(SECOND_PRECISION_ISO);
expect(entry.changedConfigKeys).toEqual(changedConfigKeys);
},
),
{ numRuns: 100 },
);
});
it('被拒绝:审计记录操作者身份与精确到秒请求时间 (Req 12.5)', () => {
fc.assert(
fc.property(
actorIdArb,
nonAdminRoleArb,
configArb,
changedKeysArb,
requestTimeArb,
(actorId, role, currentConfig, changedConfigKeys, requestTime) => {
const store = new InMemoryConfigAuditStore();
const actor: Actor = { id: actorId, role };
const request: ConfigChangeRequest<TestConfig> = {
actor,
currentConfig,
changedConfigKeys,
// 拒绝分支不应被调用;若被调用则抛错以暴露问题。
apply: () => {
throw new Error('apply 不应在拒绝分支被调用');
},
};
const result = applyConfigChange(request, store, {
clock: () => requestTime,
});
// 必为拒绝分支。
expect(result.status).toBe('rejected');
// 审计条目已写入存储(恰一条)。
const entries = store.getAll();
expect(entries).toHaveLength(1);
const entry = entries[0]!;
expect(entry).toEqual(result.auditEntry);
// 留痕字段:操作者身份、动作、精确到秒请求时间。
expect(entry.action).toBe('变更拒绝');
expect(entry.actorId).toBe(actorId);
expect(entry.timestamp).toBe(toSecondPrecisionIso(requestTime));
expect(entry.timestamp).toMatch(SECOND_PRECISION_ISO);
},
),
{ numRuns: 100 },
);
});
});
@@ -0,0 +1,98 @@
/**
* Property 45: 非管理员配置修改一律拒绝且配置不变 的属性化测试(RBAC,Req 12.1, 12.3)。
*
* 属性陈述:对任意非 Administrator 角色(含 Assessor、未认证、未授权)发起的
* Risk_Model 配置修改请求,System 必拒绝该请求(status='rejected')、保持当前配置
* 不变(返回与请求中 currentConfig 同一引用且值相等),并返回权限不足错误
* AuthorizationErroruserMessage 含「权限不足」)。
*
* 测试中 `apply` 故意产出一个不同于当前配置的新值;若拒绝逻辑被绕过而调用了
* `apply`,返回的配置将发生变化,断言即会失败——以此真实校验"配置不变"。
*
* Feature: outsourcing-risk-assessment, Property 45: 非管理员配置修改一律拒绝且配置不变
* Validates: Requirements 12.1, 12.3
*/
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import {
applyConfigChange,
AuthorizationError,
InMemoryConfigAuditStore,
type Actor,
type AuthRole,
} from '../index.js';
// 非 Administrator 角色全集(Req 12.1, 12.3)。
const NON_ADMIN_ROLES = ['Assessor', '未认证', '未授权'] as const satisfies readonly AuthRole[];
const nonAdminActorArb: fc.Arbitrary<Actor> = fc.record({
id: fc.string({ minLength: 0, maxLength: 12 }),
role: fc.constantFrom<AuthRole>(...NON_ADMIN_ROLES),
});
/** 任意配置值(以简单可比较对象代表 Risk_Model 配置形态)。 */
interface TestConfig {
readonly version: number;
readonly name: string;
}
const configArb: fc.Arbitrary<TestConfig> = fc.record({
version: fc.integer({ min: 0, max: 1_000 }),
name: fc.string({ minLength: 0, maxLength: 10 }),
});
const changedKeysArb: fc.Arbitrary<string[]> = fc.array(
fc.string({ minLength: 1, maxLength: 8 }),
{ minLength: 0, maxLength: 4 },
);
describe('Property 45: 非管理员配置修改一律拒绝且配置不变 (Req 12.1, 12.3)', () => {
it('非 Administrator 请求被拒绝、配置不变并返回权限不足错误', () => {
fc.assert(
fc.property(
nonAdminActorArb,
configArb,
changedKeysArb,
(actor, currentConfig, changedConfigKeys) => {
const auditStore = new InMemoryConfigAuditStore();
let applyCalled = false;
const result = applyConfigChange<TestConfig>(
{
actor,
currentConfig,
changedConfigKeys,
// 故意产出与当前配置不同的值:若被调用,配置必然改变。
apply: (current) => {
applyCalled = true;
return { version: current.version + 1, name: `${current.name}!` };
},
},
auditStore,
);
// 1) 一律拒绝(Req 12.1, 12.3)。
expect(result.status).toBe('rejected');
// 2) 鉴权失败时绝不调用 apply(保证配置不会被改动)。
expect(applyCalled).toBe(false);
// 3) 配置不变:同一引用且值相等(Req 12.1, 12.3)。
expect(result.config).toBe(currentConfig);
expect(result.config).toEqual(currentConfig);
// 4) 返回权限不足错误(Req 12.1, 12.3)。
if (result.status === 'rejected') {
expect(result.error).toBeInstanceOf(AuthorizationError);
expect(result.error.userMessage).toContain('权限不足');
expect(result.error.requiredRole).toBe('Administrator');
expect(result.error.actualRole).toBe(actor.role);
expect(result.error.actorId).toBe(actor.id);
}
},
),
{ numRuns: 100 },
);
});
});
@@ -0,0 +1,194 @@
/**
* Property 48: 组合看板汇总与空集处理 的属性化测试(PortfolioReq 13.4, 13.6)。
*
* 属性陈述:*对任意* Assessment 集合,在请求者已分配视图角色(非"无角色")时,
* 管理层组合看板*必*汇总集合中的**全部**评估——
* - `entries` 与输入集合一一对应且顺序一致(汇总全部评估,Req 13.4);
* - `totalCount` 等于输入集合长度,亦等于 `entries.length`
* - 每条 `entry` 的索引字段忠实取自对应 Assessment(id 与元数据口径一致);
* - 风险分级分布按 Risk_Grade 正确计数,各分级计数之和等于 `totalCount`
* 且每一分级计数等于集合中该分级评估的真实数量;
* - 非空集时 `isEmpty` 为 `false` 且不给出空看板提示。
*
* 当集合为空时,*必*返回空组合看板(`isEmpty` 为 `true`、`totalCount` 为 0、`entries`
* 为空、各分级计数为 0)并附无可汇总评估的提示 {@link EMPTY_PORTFOLIO_PROMPT}Req 13.6)。
*
* 生成器覆盖任意大小的集合(含空集)、全部业务类型/行业/风险分级形态、有/无可接受性
* 结论,并在已分配视图角色(商务/销售、风控、管理层)之间任取,以校验"汇总全部评估"
* 不依赖具体已分配角色。
*
* Feature: outsourcing-risk-assessment, Property 48: 组合看板汇总与空集处理
* Validates: Requirements 13.4, 13.6
*/
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import {
ACCEPTABILITY_VALUES,
type Acceptability,
type Assessment,
} from '../../domain/assessment.js';
import {
BUSINESS_TYPE_VALUES,
RISK_GRADE_VALUES,
type BusinessType,
type RiskGrade,
} from '../../domain/common.js';
import type { RiskModel } from '../../domain/model.js';
import { REGION_CN } from '../../domain/region.js';
import { VIEW_ROLE_VALUES, type ViewRole } from '../views.js';
import { EMPTY_PORTFOLIO_PROMPT, renderPortfolio } from '../renderPortfolio.js';
// ----------------------------------------------------------------------------
// 生成器:仅约束组合看板读取的索引字段,其余以最小合法形态填充
// ----------------------------------------------------------------------------
const businessTypeArb = fc.constantFrom<BusinessType>(
...(BUSINESS_TYPE_VALUES as readonly BusinessType[]),
);
const riskGradeArb = fc.constantFrom<RiskGrade>(...(RISK_GRADE_VALUES as readonly RiskGrade[]));
const acceptabilityArb = fc.constantFrom<Acceptability>(
...(ACCEPTABILITY_VALUES as readonly Acceptability[]),
);
/** 行业为自由文本(含"未识别"占位的可能);以非空字符串覆盖典型形态。 */
const industryArb = fc.string({ minLength: 0, maxLength: 12 });
const riskScoreArb = fc.integer({ min: 0, max: 100 });
const assignedRoleArb = fc.constantFrom<ViewRole>(...(VIEW_ROLE_VALUES as readonly ViewRole[]));
/** 组合看板不读取风险模型内容,故以最小合法快照填充。 */
function minimalRiskModel(businessType: BusinessType): RiskModel {
return {
id: 'm',
name: '风险模型',
businessType,
dimensions: [],
redlines: [],
};
}
interface AssessmentSpec {
id: string;
businessType: BusinessType;
industry: string;
riskScore: number;
riskGrade: RiskGrade;
/** 可接受性结论可能尚未生成(undefined)。 */
acceptability: Acceptability | undefined;
createdAt: string;
}
const assessmentSpecArb: fc.Arbitrary<AssessmentSpec> = fc.record({
id: fc.string({ minLength: 1, maxLength: 8 }),
businessType: businessTypeArb,
industry: industryArb,
riskScore: riskScoreArb,
riskGrade: riskGradeArb,
acceptability: fc.option(acceptabilityArb, { nil: undefined }),
createdAt: fc
.integer({ min: 0, max: 4_102_444_800_000 })
.map((ms) => new Date(ms).toISOString()),
});
/** 由规格构造一份"已完成"评估;元数据与顶层字段在看板读取口径上保持一致。 */
function buildAssessment(spec: AssessmentSpec): Assessment {
const base: Assessment = {
id: spec.id,
projectDescription: '某外包项目风险评估',
businessType: spec.businessType,
industry: spec.industry,
region: REGION_CN,
riskModel: minimalRiskModel(spec.businessType),
scoringItems: [],
riskScore: spec.riskScore,
riskGrade: spec.riskGrade,
redlineResults: [],
metadata: {
businessType: spec.businessType,
industry: spec.industry,
region: REGION_CN,
riskScore: spec.riskScore,
riskGrade: spec.riskGrade,
createdAt: spec.createdAt,
assessorId: 'assessor-1',
},
createdAt: spec.createdAt,
assessorId: 'assessor-1',
};
// exactOptionalPropertyTypes:仅在存在时附带可接受性结论。
return spec.acceptability !== undefined
? { ...base, acceptability: spec.acceptability }
: base;
}
// ----------------------------------------------------------------------------
// 属性测试
// ----------------------------------------------------------------------------
describe('Property 48: 组合看板汇总与空集处理 (Req 13.4, 13.6)', () => {
it('对任意已分配角色与任意评估集合,组合看板汇总全部评估', () => {
fc.assert(
fc.property(
assignedRoleArb,
fc.array(assessmentSpecArb, { minLength: 0, maxLength: 12 }),
(role, specs) => {
const assessments = specs.map(buildAssessment);
const view = renderPortfolio(role, assessments);
// --- Req 13.4:汇总全部评估,entries 一一对应且顺序一致 ---
expect(view.totalCount).toBe(assessments.length);
expect(view.entries.length).toBe(assessments.length);
expect(view.isEmpty).toBe(assessments.length === 0);
assessments.forEach((assessment, i) => {
const entry = view.entries[i];
expect(entry).toBeDefined();
if (entry === undefined) return;
expect(entry.assessmentId).toBe(assessment.id);
expect(entry.businessType).toBe(assessment.metadata.businessType);
expect(entry.industry).toBe(assessment.metadata.industry);
expect(entry.riskScore).toBe(assessment.metadata.riskScore);
expect(entry.riskGrade).toBe(assessment.metadata.riskGrade);
expect(entry.createdAt).toBe(assessment.metadata.createdAt);
// 可接受性结论:存在则透传,否则不附带(exactOptionalPropertyTypes)。
expect(entry.acceptability).toBe(assessment.acceptability);
});
// --- Req 13.4:风险分级分布正确计数,且各计数之和等于 totalCount ---
let distributionSum = 0;
for (const grade of RISK_GRADE_VALUES) {
const expectedCount = assessments.filter(
(a) => a.metadata.riskGrade === grade,
).length;
expect(view.gradeDistribution[grade]).toBe(expectedCount);
distributionSum += view.gradeDistribution[grade];
}
expect(distributionSum).toBe(view.totalCount);
// --- 非空集:不展示空看板提示 ---
if (assessments.length > 0) {
expect(view.prompt).toBeUndefined();
}
},
),
{ numRuns: 100 },
);
});
it('对空评估集合,展示空组合看板并提示无可汇总的评估 (Req 13.6)', () => {
fc.assert(
fc.property(assignedRoleArb, (role) => {
const view = renderPortfolio(role, []);
expect(view.isEmpty).toBe(true);
expect(view.totalCount).toBe(0);
expect(view.entries).toEqual([]);
expect(view.prompt).toBe(EMPTY_PORTFOLIO_PROMPT);
// 空集时各分级计数恒为 0。
for (const grade of RISK_GRADE_VALUES) {
expect(view.gradeDistribution[grade]).toBe(0);
}
}),
{ numRuns: 100 },
);
});
});
@@ -0,0 +1,200 @@
/**
* Property 49: 无角色拒绝展示 的属性化测试(RBAC 视图层,Req 13.5)。
*
* 属性陈述:*对任意*用户与*任意*待汇总的评估集合(含空集与非空集):
* - 当用户未分配任一视图角色("无角色")时,组合看板入口 {@link renderPortfolio} 与
* 前置守卫 {@link requireAssignedRole} *必*拒绝展示,抛出 {@link RoleAssignmentRequiredError}
* (提示固定包含"需分配角色"),且*不*返回任何评估数据——拒绝发生在汇总任何评估数据之前;
* - 当用户已分配视图角色(商务/销售 / 风控 / 管理层)时,*不会*因"无角色"而被拒绝,
* 守卫放行、`renderPortfolio` 正常返回,且 {@link RoleAssignmentRequiredError} 不被抛出。
*
* "拒绝发生在读取评估数据之前"以**陷阱数组(tripwire)**佐证:将评估集合包装为一旦被
* 索引/迭代/读取 `length` 即抛出独立错误的 Proxy;对无角色用户调用时,抛出的应当是
* {@link RoleAssignmentRequiredError} 而非陷阱错误,从而证明未触碰任何评估数据。
*
* Feature: outsourcing-risk-assessment, Property 49: 无角色拒绝展示
* Validates: Requirements 13.5
*/
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import { RISK_GRADE_VALUES, BUSINESS_TYPE_VALUES } from '../../domain/common.js';
import type { BusinessType, RiskGrade } from '../../domain/common.js';
import type { Assessment } from '../../domain/assessment.js';
import { ACCEPTABILITY_VALUES } from '../../domain/assessment.js';
import type { Acceptability } from '../../domain/assessment.js';
import type { RiskModel } from '../../domain/model.js';
import { REGION_CN } from '../../domain/region.js';
import { RoleAssignmentRequiredError } from '../errors.js';
import {
NO_ROLE,
hasAssignedRole,
renderPortfolio,
requireAssignedRole,
} from '../renderPortfolio.js';
import { VIEW_ROLE_VALUES } from '../views.js';
import type { ViewRole } from '../views.js';
// ----------------------------------------------------------------------------
// 生成器:构造最小但合法的 Assessment(组合看板仅读取元数据索引字段与 id/acceptability
// ----------------------------------------------------------------------------
/** 组合看板不读取风险模型内部结构,给出最小合法占位即可。 */
const minimalRiskModel: RiskModel = {
id: 'm1',
name: '风险模型',
businessType: '岗位外包',
dimensions: [],
redlines: [],
};
const businessTypeArb = fc.constantFrom<BusinessType>(
...(BUSINESS_TYPE_VALUES as readonly BusinessType[]),
);
const riskGradeArb = fc.constantFrom<RiskGrade>(...(RISK_GRADE_VALUES as readonly RiskGrade[]));
const acceptabilityArb = fc.constantFrom<Acceptability>(
...(ACCEPTABILITY_VALUES as readonly Acceptability[]),
);
interface AssessmentSpec {
id: string;
businessType: BusinessType;
industry: string;
riskScore: number;
riskGrade: RiskGrade;
/** 可接受性结论可能尚未生成(exactOptionalPropertyTypes)。 */
acceptability: Acceptability | null;
}
const assessmentSpecArb: fc.Arbitrary<AssessmentSpec> = fc.record({
id: fc.string({ minLength: 1, maxLength: 12 }),
businessType: businessTypeArb,
industry: fc.string({ minLength: 0, maxLength: 12 }),
riskScore: fc.integer({ min: 0, max: 100 }),
riskGrade: riskGradeArb,
acceptability: fc.option(acceptabilityArb, { nil: null }),
});
/** 由规格构造最小合法 Assessment(仅填充组合看板会读取/类型要求的字段)。 */
function buildAssessment(spec: AssessmentSpec): Assessment {
return {
id: spec.id,
projectDescription: '某外包项目风险评估',
businessType: spec.businessType,
industry: spec.industry,
region: REGION_CN,
riskModel: minimalRiskModel,
scoringItems: [],
riskScore: spec.riskScore,
riskGrade: spec.riskGrade,
redlineResults: [],
metadata: {
businessType: spec.businessType,
industry: spec.industry,
region: REGION_CN,
riskScore: spec.riskScore,
riskGrade: spec.riskGrade,
createdAt: '2024-01-01T00:00:00.000Z',
assessorId: 'assessor-1',
},
createdAt: '2024-01-01T00:00:00.000Z',
assessorId: 'assessor-1',
...(spec.acceptability !== null ? { acceptability: spec.acceptability } : {}),
};
}
/** 已分配视图角色生成器(商务/销售 / 风控 / 管理层)。 */
const assignedRoleArb = fc.constantFrom<ViewRole>(...(VIEW_ROLE_VALUES as readonly ViewRole[]));
// ----------------------------------------------------------------------------
// 陷阱数组(tripwire):一旦被读取(索引/迭代/length 等)即抛出独立错误,
// 用以佐证无角色拒绝发生在读取任何评估数据之前。
// ----------------------------------------------------------------------------
class TripwireAccessError extends Error {
constructor() {
super('TRIPWIRE: 评估数据在被拒绝之前不应被读取');
this.name = 'TripwireAccessError';
}
}
/** 把评估集合包装为"任何属性读取都会触发陷阱"的只读数组视图。 */
function makeTripwire(assessments: readonly Assessment[]): readonly Assessment[] {
const handler: ProxyHandler<readonly Assessment[]> = {
get(): never {
throw new TripwireAccessError();
},
has(): never {
throw new TripwireAccessError();
},
ownKeys(): never {
throw new TripwireAccessError();
},
};
return new Proxy(assessments, handler) as readonly Assessment[];
}
// ----------------------------------------------------------------------------
// 属性测试
// ----------------------------------------------------------------------------
describe('Property 49: 无角色拒绝展示 (Req 13.5)', () => {
it('无角色用户被拒绝展示(抛 RoleAssignmentRequiredError、提示需分配角色、不返回评估数据),已分配角色不因此被拒', () => {
fc.assert(
fc.property(
fc.array(assessmentSpecArb, { minLength: 0, maxLength: 6 }),
assignedRoleArb,
(specs, assignedRole) => {
const assessments = specs.map(buildAssessment);
// --- 无角色:守卫与组合看板均拒绝展示(Req 13.5) ---
expect(hasAssignedRole(NO_ROLE)).toBe(false);
// 前置守卫拒绝无角色用户。
expect(() => {
requireAssignedRole(NO_ROLE);
}).toThrow(RoleAssignmentRequiredError);
// 组合看板拒绝无角色用户,且拒绝发生在读取任何评估数据之前:
// 用陷阱数组验证抛出的是 RoleAssignmentRequiredError 而非陷阱错误,
// 即未触碰任何评估数据,因而不返回任何评估数据。
let captured: unknown;
try {
renderPortfolio(NO_ROLE, makeTripwire(assessments));
// 不应到达此处(renderPortfolio 必抛错)。
expect.unreachable('renderPortfolio(无角色) 应抛出 RoleAssignmentRequiredError');
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(RoleAssignmentRequiredError);
expect(captured).not.toBeInstanceOf(TripwireAccessError);
const error = captured as RoleAssignmentRequiredError;
// 提示固定包含"需分配角色"Req 13.5)。
expect(error.userMessage).toContain('需分配角色');
// 错误不携带任何评估数据:仅有 userMessage 这类提示字段,无 entries/portfolio。
const ownKeys = Object.keys(error);
expect(ownKeys).not.toContain('entries');
expect(ownKeys).not.toContain('assessments');
expect(ownKeys).not.toContain('portfolio');
// --- 已分配角色:不因"无角色"被拒,守卫放行、组合看板正常返回(Req 13.4/13.5 ---
expect(hasAssignedRole(assignedRole)).toBe(true);
expect(() => {
requireAssignedRole(assignedRole);
}).not.toThrow();
let view: ReturnType<typeof renderPortfolio> | undefined;
expect(() => {
view = renderPortfolio(assignedRole, assessments);
}).not.toThrow(RoleAssignmentRequiredError);
// 正常返回组合看板,纳入全部评估(不丢弃任何数据)。
expect(view).toBeDefined();
expect(view?.totalCount).toBe(assessments.length);
expect(view?.entries.length).toBe(assessments.length);
expect(view?.isEmpty).toBe(assessments.length === 0);
},
),
{ numRuns: 100 },
);
});
});
@@ -0,0 +1,329 @@
/**
* Property 47: 角色化视图内容映射 的属性化测试(ViewsReq 13.1-13.3)。
*
* 属性陈述:*对任意*已完成评估(风险评分、红线校验、费用测算与可接受性结论均已完成):
* - 商务/销售视图({@link SalesView}Req 13.1)*必*包含可接受性结论、接受条件清单与
* 风险调整后报价,且三者与报告/费用测算口径一致;
* - 风控视图({@link RiskView}Req 13.2*必*包含评分项明细(含 Risk_Level、判定依据、
* Data_Provenance)、红线校验结果与信息缺口尽调事项,且与评估/报告口径一致;
* - 管理层视图({@link ManagementView}Req 13.3*必*包含 Risk_Grade、风险热力图、
* Top N 关键风险与利润对风险对比,且与报告/费用测算口径一致。
*
* 本测试在生成器中构造"完整 Assessment"(评分/红线/费用/可接受性结论均已产生),覆盖
* 启用/停用维度与指标、空指标维度、混合数据来源(含"智能体假设")、有/无红线命中、
* 任意可接受性结论与可配置 Top N 等形态,对三类角色视图施加上述内容映射约束。以同一份
* 已完成 Assessment 经 {@link generate} 产出的报告作为投影口径的对照(oracle)。
*
* Feature: outsourcing-risk-assessment, Property 47: 角色化视图内容映射
* Validates: Requirements 13.1, 13.2, 13.3
*/
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import {
ACCEPTABILITY_VALUES,
REDLINE_STATUS_VALUES,
type Acceptability,
type Assessment,
type RedlineResult,
type RedlineStatus,
type ScoringItem,
} from '../../domain/assessment.js';
import {
DATA_PROVENANCE_VALUES,
RISK_GRADE_VALUES,
RISK_LEVEL_VALUES,
type DataProvenance,
type RiskGrade,
type RiskLevel,
} from '../../domain/common.js';
import type { CostEstimate } from '../../domain/cost.js';
import type { Dimension, Indicator, Redline, RiskModel, ScoringRule } from '../../domain/model.js';
import { REGION_CN } from '../../domain/region.js';
import { generate } from '../../report/index.js';
import { renderView } from '../renderView.js';
import type { ManagementView, RiskView, SalesView } from '../views.js';
// ----------------------------------------------------------------------------
// 生成器:构造"评分/红线/费用/可接受性结论均已完成"的 Assessment
// ----------------------------------------------------------------------------
/** 合法 Indicator 须覆盖 Risk_Level 1-5Req 11.3);视图/报告不读取规则文本。 */
const scoringRules: ScoringRule[] = (RISK_LEVEL_VALUES as readonly RiskLevel[]).map((level) => ({
level,
label: `L${level}`,
description: `规则${level}`,
}));
const riskLevelArb = fc.constantFrom<RiskLevel>(...(RISK_LEVEL_VALUES as readonly RiskLevel[]));
const provenanceArb = fc.constantFrom<DataProvenance>(
...(DATA_PROVENANCE_VALUES as readonly DataProvenance[]),
);
const riskGradeArb = fc.constantFrom<RiskGrade>(...(RISK_GRADE_VALUES as readonly RiskGrade[]));
const acceptabilityArb = fc.constantFrom<Acceptability>(
...(ACCEPTABILITY_VALUES as readonly Acceptability[]),
);
const redlineStatusArb = fc.constantFrom<RedlineStatus>(
...(REDLINE_STATUS_VALUES as readonly RedlineStatus[]),
);
/** 置信度:[0,1] 两位小数(Req 4.6, 18.4)。 */
const confidenceArb = fc.integer({ min: 0, max: 100 }).map((n) => n / 100);
const weightArb = fc.double({ min: 0, max: 100, noNaN: true });
const moneyArb = fc.double({ min: 0, max: 1_000_000, noNaN: true });
interface RawIndicator {
enabled: boolean;
weight: number;
riskLevel: RiskLevel;
provenance: DataProvenance;
confidence: number;
}
interface RawDimension {
enabled: boolean;
weight: number;
indicators: RawIndicator[];
}
interface RawRedline {
enabled: boolean;
status: RedlineStatus;
}
const rawIndicatorArb: fc.Arbitrary<RawIndicator> = fc.record({
enabled: fc.boolean(),
weight: weightArb,
riskLevel: riskLevelArb,
provenance: provenanceArb,
confidence: confidenceArb,
});
const rawDimensionArb: fc.Arbitrary<RawDimension> = fc.record({
enabled: fc.boolean(),
weight: weightArb,
indicators: fc.array(rawIndicatorArb, { minLength: 0, maxLength: 4 }),
});
const rawRedlineArb: fc.Arbitrary<RawRedline> = fc.record({
enabled: fc.boolean(),
status: redlineStatusArb,
});
/** 风险调整后报价恒不低于基准报价(Req 8.3):以非负溢价构造。 */
const costEstimateArb: fc.Arbitrary<CostEstimate> = fc
.record({
baseline: moneyArb,
premium: fc.double({ min: 0, max: 500_000, noNaN: true }),
premiumLower: fc.double({ min: 0, max: 50, noNaN: true }),
premiumDelta: fc.double({ min: 0, max: 50, noNaN: true }),
})
.map(({ baseline, premium, premiumLower, premiumDelta }) => ({
riskPremiumRange: { lower: premiumLower, upper: premiumLower + premiumDelta, unit: '百分比' },
advanceInterest: 0,
insuranceCost: 0,
compensationReserve: 0,
badDebtReserve: 0,
baselineQuote: baseline,
riskAdjustedQuote: baseline + premium,
breakdown: [],
}));
interface AssessmentSpec {
dimensions: RawDimension[];
redlines: RawRedline[];
riskScore: number;
riskGrade: RiskGrade;
acceptability: Acceptability;
costEstimate: CostEstimate;
/** 管理层视图的可配置 Top N1-50Req 7.2)。 */
topN: number;
/** 强制构造"无红线命中"情形,确保该分支被充分覆盖。 */
forceNoHit: boolean;
}
const assessmentSpecArb: fc.Arbitrary<AssessmentSpec> = fc.record({
dimensions: fc.array(rawDimensionArb, { minLength: 0, maxLength: 4 }),
redlines: fc.array(rawRedlineArb, { minLength: 0, maxLength: 5 }),
riskScore: fc.integer({ min: 0, max: 100 }),
riskGrade: riskGradeArb,
acceptability: acceptabilityArb,
costEstimate: costEstimateArb,
topN: fc.integer({ min: 1, max: 50 }),
forceNoHit: fc.boolean(),
});
/**
* 由随机规格构造一个"已完成"的 Assessment
* - 风险模型保留全部随机形态(启用/停用维度与指标、空指标维度、权重为 0 等);
* - 每个 Indicator 均有对应 ScoringItem(含来源/置信/三要素);
* - 每个**启用**红线均有对应的红线校验结果(满足红线校验完成条件);
* - riskScore/riskGrade/costEstimate/acceptability 均已产生。
*/
function buildAssessment(spec: AssessmentSpec): Assessment {
const dimensions: Dimension[] = spec.dimensions.map((d, i) => ({
id: `d${i}`,
name: `维度${i}`,
weight: d.weight,
enabled: d.enabled,
indicators: d.indicators.map(
(ind, j): Indicator => ({
id: `i${j}`,
name: `指标${i}-${j}`,
weight: ind.weight,
enabled: ind.enabled,
scoringRules,
evidenceRequired: '',
askPrompt: '',
}),
),
}));
const redlines: Redline[] = spec.redlines.map((r, k) => ({
id: `r${k}`,
triggerCondition: `触发条件${k}`,
consequence: `后果${k}`,
enabled: r.enabled,
}));
const riskModel: RiskModel = {
id: 'm1',
name: '风险模型',
businessType: '岗位外包',
dimensions,
redlines,
};
// 为每个指标构造评分项(含停用项;视图/报告以对象引用映射,停用项不进入热力图/TopN)。
const scoringItems: ScoringItem[] = spec.dimensions.flatMap((d, i) =>
d.indicators.map(
(ind, j): ScoringItem => ({
dimensionId: `d${i}`,
indicatorId: `i${j}`,
riskLevel: ind.riskLevel,
score: ind.riskLevel * ind.weight,
provenance: ind.provenance,
confidence: ind.confidence,
rationale: `判定依据${i}-${j}`,
riskImpact: `风险影响${i}-${j}`,
recommendation: `建议${i}-${j}`,
}),
),
);
// 每个启用红线产生一个红线校验结果(满足红线校验完成条件)。
const redlineResults: RedlineResult[] = spec.redlines
.map((r, k): { enabled: boolean; result: RedlineResult } => {
const status: RedlineStatus = spec.forceNoHit && r.status === '命中' ? '未命中' : r.status;
const base: RedlineResult = { redlineId: `r${k}`, status };
const result: RedlineResult =
status === '命中'
? { ...base, triggeredCondition: `触发条件${k}`, evidenceData: `判定依据数据${k}` }
: base;
return { enabled: r.enabled, result };
})
.filter((entry) => entry.enabled)
.map((entry) => entry.result);
return {
id: 'a1',
projectDescription: '某外包项目风险评估',
businessType: '岗位外包',
industry: '制造业',
region: REGION_CN,
riskModel,
scoringItems,
riskScore: spec.riskScore,
riskGrade: spec.riskGrade,
redlineResults,
costEstimate: spec.costEstimate,
acceptability: spec.acceptability,
metadata: {
businessType: '岗位外包',
industry: '制造业',
region: REGION_CN,
riskScore: spec.riskScore,
riskGrade: spec.riskGrade,
createdAt: '2024-01-01T00:00:00.000Z',
assessorId: 'assessor-1',
},
createdAt: '2024-01-01T00:00:00.000Z',
assessorId: 'assessor-1',
};
}
// ----------------------------------------------------------------------------
// 属性测试
// ----------------------------------------------------------------------------
describe('Property 47: 角色化视图内容映射 (Req 13.1, 13.2, 13.3)', () => {
it('对任意已完成评估,三类角色视图必包含各自规定内容且与报告/费用测算口径一致', () => {
fc.assert(
fc.property(assessmentSpecArb, (spec) => {
const assessment = buildAssessment(spec);
const costEstimate = spec.costEstimate;
// 以同一份已完成 Assessment 的报告作为投影口径对照(oracle)。
const report = generate(assessment, { topN: spec.topN });
// --- 商务/销售视图(Req 13.1):结论 + 接受条件清单 + 风险调整后报价 ---
const salesView = renderView('商务/销售', assessment, { topN: spec.topN }) as SalesView;
expect(salesView.role).toBe('商务/销售');
// 可接受性结论(与报告口径一致)。
expect(salesView.acceptability).toBe(report.acceptabilityConclusion.acceptability);
expect(ACCEPTABILITY_VALUES).toContain(salesView.acceptability);
// 接受条件清单(与报告应对方案口径一致)。
expect(Array.isArray(salesView.acceptanceConditions)).toBe(true);
expect(salesView.acceptanceConditions).toEqual(report.responsePlan.acceptanceConditions);
// 风险调整后报价(与费用测算口径一致,且恒不低于基准报价,Req 8.3)。
expect(salesView.riskAdjustedQuote).toBe(costEstimate.riskAdjustedQuote);
expect(salesView.riskAdjustedQuote).toBeGreaterThanOrEqual(salesView.baselineQuote);
// --- 风控视图(Req 13.2):评分明细 + 红线结果 + 信息缺口尽调 ---
const riskViewResult = renderView('风控', assessment, { topN: spec.topN }) as RiskView;
expect(riskViewResult.role).toBe('风控');
// 评分项明细:与评估评分项一致,且每项含 Risk_Level / 判定依据 / Data_Provenance。
expect(riskViewResult.scoringItems).toEqual(assessment.scoringItems);
expect(riskViewResult.scoringItems.length).toBe(assessment.scoringItems.length);
for (const item of riskViewResult.scoringItems) {
expect(RISK_LEVEL_VALUES).toContain(item.riskLevel);
expect(typeof item.rationale).toBe('string');
expect(item.rationale.length).toBeGreaterThan(0);
expect(DATA_PROVENANCE_VALUES).toContain(item.provenance);
}
// 红线校验结果(与报告/评估口径一致)。
expect(riskViewResult.redlineResults).toEqual(report.keyRisksAndRedlines.redlineResults);
expect(riskViewResult.redlineResults).toEqual(assessment.redlineResults);
// 信息缺口尽调事项(与报告假设与信息缺口口径一致;来源恒为"智能体假设")。
expect(riskViewResult.informationGapDueDiligence).toEqual(report.assumptionsAndGaps.items);
for (const gap of riskViewResult.informationGapDueDiligence) {
expect(gap.provenance).toBe('智能体假设');
}
// --- 管理层视图(Req 13.3):Grade + 热力图 + Top N + 利润对风险对比 ---
const managementView = renderView('管理层', assessment, {
topN: spec.topN,
}) as ManagementView;
expect(managementView.role).toBe('管理层');
// Risk_Grade(与报告口径一致)。
expect(managementView.riskGrade).toBe(report.riskScoreGrade.riskGrade);
expect(RISK_GRADE_VALUES).toContain(managementView.riskGrade);
// 风险热力图(与报告口径一致)。
expect(managementView.heatmap).toEqual(report.heatmap.cells);
// Top N 关键风险(与报告口径一致,且条目数不超过 N)。
expect(managementView.topKeyRisks).toEqual(report.keyRisksAndRedlines.topKeyRisks);
expect(managementView.topKeyRisks.length).toBeLessThanOrEqual(spec.topN);
// 利润对风险对比:分级/总分/报价并置,风险溢价 = 风险调整后报价 − 基准报价(恒非负)。
expect(managementView.profitVsRisk.riskGrade).toBe(report.riskScoreGrade.riskGrade);
expect(managementView.profitVsRisk.riskScore).toBe(report.riskScoreGrade.riskScore);
expect(managementView.profitVsRisk.baselineQuote).toBe(costEstimate.baselineQuote);
expect(managementView.profitVsRisk.riskAdjustedQuote).toBe(costEstimate.riskAdjustedQuote);
expect(managementView.profitVsRisk.riskPremium).toBeCloseTo(
costEstimate.riskAdjustedQuote - costEstimate.baselineQuote,
6,
);
expect(managementView.profitVsRisk.riskPremium).toBeGreaterThanOrEqual(0);
}),
{ numRuns: 100 },
);
});
});
+39
View File
@@ -0,0 +1,39 @@
/**
* 配置变更审计日志存储抽象与内存实现(Req 12.4, 12.5)。
*
* 通过抽象的 {@link ConfigAuditStore} 接口隔离日志介质,便于替换实现与测试。
* {@link InMemoryConfigAuditStore} 为进程内默认实现,按追加顺序保留全部审计条目,
* 供单元/集成测试与无外部依赖场景使用。
*/
import type { ConfigAuditEntry } from '../domain/audit.js';
/**
* 配置变更审计日志存储抽象接口(Req 12.4, 12.5)。
*
* 成功变更(变更提交)与被拒绝请求(变更拒绝)均通过 {@link append} 追加留痕。
*/
export interface ConfigAuditStore {
/** 追加一条审计条目(按调用顺序保留)。 */
append(entry: ConfigAuditEntry): void;
/** 返回全部审计条目的快照列表(按追加顺序)。 */
getAll(): ConfigAuditEntry[];
}
/**
* 进程内审计日志存储实现(Req 12.4, 12.5)。
*
* 以数组按追加顺序保留审计条目。仅用于测试与无外部依赖场景,
* 进程退出后数据不保留。`getAll` 返回防御性副本,避免外部修改内部状态。
*/
export class InMemoryConfigAuditStore implements ConfigAuditStore {
private readonly entries: ConfigAuditEntry[] = [];
append(entry: ConfigAuditEntry): void {
this.entries.push(entry);
}
getAll(): ConfigAuditEntry[] {
return [...this.entries];
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* 受 RBAC 守卫的配置修改与审计(Req 12.1, 12.3, 12.4, 12.5)。
*
* `applyConfigChange` 是 Risk_Model 配置写操作的统一入口:先经 {@link requireRole}
* 鉴权,仅 Administrator 可提交变更;非 Administrator(含 Assessor、未认证、未授权)
* 一律拒绝、保持当前配置不变并返回权限不足错误(Req 12.1, 12.3)。
* 无论提交还是拒绝,均向审计日志留痕(Req 12.4, 12.5):
* - 变更提交:记录操作者身份、精确到秒的变更时间与发生变更的配置项标识(Req 12.4)。
* - 变更拒绝:记录操作者身份与精确到秒的请求时间(Req 12.5)。
*/
import type { ConfigAuditEntry } from '../domain/audit.js';
import type { ConfigAuditStore } from './auditStore.js';
import { AuthorizationError } from './errors.js';
import { canModifyConfig } from './requireRole.js';
import type { Actor } from './roles.js';
/**
* 时钟函数:返回当前时间。默认使用系统时钟,测试可注入确定性时钟。
*/
export type Clock = () => Date;
/** 默认系统时钟。 */
const systemClock: Clock = () => new Date();
/**
* 将时间格式化为精确到秒的 ISO 8601 字符串(Req 12.4, 12.5)。
*
* 通过截断毫秒(向下取整到整秒)保证时间戳精确到秒,形如 `2024-01-01T08:30:15.000Z`。
*
* @param date 待格式化的时间。
* @returns 精确到秒(毫秒部分为 000)的 ISO 8601 字符串。
*/
export function toSecondPrecisionIso(date: Date): string {
const millisPerSecond = 1000;
const truncated = new Date(
Math.floor(date.getTime() / millisPerSecond) * millisPerSecond,
);
return truncated.toISOString();
}
/**
* 配置修改请求(与具体配置形态解耦的泛型)。
*
* @typeParam C 配置类型(如 Config_Center 的 RiskModelConfig)。
*/
export interface ConfigChangeRequest<C> {
/** 发起修改的操作者(含身份标识与配置访问角色)。 */
actor: Actor;
/** 修改前的当前配置;拒绝时原样返回,保证配置不变(Req 12.1, 12.3)。 */
currentConfig: C;
/** 本次请求拟变更的配置项标识(成功时记入审计,Req 12.4)。 */
changedConfigKeys: readonly string[];
/**
* 配置变更函数:仅在鉴权通过时被调用,基于当前配置产出新配置。
* 鉴权失败时不会被调用,从而保证非授权请求绝不改动配置。
*/
apply: (current: C) => C;
}
/** `applyConfigChange` 的可选项。 */
export interface ApplyConfigChangeOptions {
/** 时钟注入,便于测试确定性时间。默认系统时钟。 */
clock?: Clock;
}
/**
* 配置变更成功结果(Req 12.4)。
*/
export interface ConfigChangeCommitted<C> {
readonly status: 'committed';
/** 变更后的新配置。 */
readonly config: C;
/** 已写入的"变更提交"审计条目。 */
readonly auditEntry: ConfigAuditEntry;
}
/**
* 配置变更被拒绝结果(Req 12.1, 12.3, 12.5)。
*/
export interface ConfigChangeRejected<C> {
readonly status: 'rejected';
/** 保持不变的当前配置(与请求中的 `currentConfig` 同一引用,Req 12.1, 12.3)。 */
readonly config: C;
/** 权限不足错误(Req 12.1, 12.3)。 */
readonly error: AuthorizationError;
/** 已写入的"变更拒绝"审计条目。 */
readonly auditEntry: ConfigAuditEntry;
}
/**
* `applyConfigChange` 的返回结果(不抛出异常)。
*
* 以判别联合区分提交与拒绝,便于上层在不依赖异常控制流的前提下处理两种分支。
*/
export type ConfigChangeResult<C> =
| ConfigChangeCommitted<C>
| ConfigChangeRejected<C>;
/**
* 执行一次受 RBAC 守卫并留痕审计的配置修改(Req 12.1, 12.3, 12.4, 12.5)。
*
* 行为:
* - 当操作者为 Administrator:调用 `apply` 产出新配置,记录"变更提交"审计条目
* (操作者身份 + 精确到秒变更时间 + 变更项标识,Req 12.4),返回 {@link ConfigChangeCommitted}。
* - 当操作者非 Administrator(含 Assessor、未认证、未授权):不调用 `apply`,
* 原样返回当前配置(配置不变,Req 12.1, 12.3),记录"变更拒绝"审计条目
* (操作者身份 + 精确到秒请求时间,Req 12.5),返回携带 {@link AuthorizationError}
* 的 {@link ConfigChangeRejected}。
*
* 不抛出异常:权限不足以 {@link ConfigChangeRejected.error} 承载,便于审计与会话内继续处理。
*
* @param request 配置修改请求。
* @param auditStore 审计日志存储。
* @param options 可选项(如注入时钟)。
* @returns 提交或拒绝的判别联合结果。
*/
export function applyConfigChange<C>(
request: ConfigChangeRequest<C>,
auditStore: ConfigAuditStore,
options: ApplyConfigChangeOptions = {},
): ConfigChangeResult<C> {
const clock = options.clock ?? systemClock;
const { actor, currentConfig, changedConfigKeys, apply } = request;
const timestamp = toSecondPrecisionIso(clock());
// 非 Administrator:拒绝、配置不变、留痕(Req 12.1, 12.3, 12.5)。
if (!canModifyConfig(actor)) {
const error = new AuthorizationError(actor.id, 'Administrator', actor.role);
const auditEntry: ConfigAuditEntry = {
actorId: actor.id,
timestamp,
action: '变更拒绝',
// 拒绝仅要求记录操作者与请求时间(Req 12.5);不记录变更项(配置未变更)。
changedConfigKeys: [],
reason: error.userMessage,
};
auditStore.append(auditEntry);
return {
status: 'rejected',
config: currentConfig,
error,
auditEntry,
};
}
// Administrator:提交变更并留痕(Req 12.4)。
const config = apply(currentConfig);
const auditEntry: ConfigAuditEntry = {
actorId: actor.id,
timestamp,
action: '变更提交',
changedConfigKeys: [...changedConfigKeys],
};
auditStore.append(auditEntry);
return {
status: 'committed',
config,
auditEntry,
};
}
+89
View File
@@ -0,0 +1,89 @@
/**
* RBAC 鉴权错误类型(Req 12.1, 12.3)。
*
* 当非 Administrator 操作者请求修改 Risk_Model 配置时,鉴权失败并抛出/返回
* 语义化的权限不足错误,供上层捕获并向操作者返回指示权限不足的提示。
*/
import type { AuthRole } from './roles.js';
import type { ViewRole } from './views.js';
/**
* 权限不足错误(Req 12.1, 12.3)。
*
* 当操作者角色不满足执行某操作所需角色(如非 Administrator 请求修改配置)时抛出。
* `userMessage` 为面向操作者的可读提示,固定包含"权限不足"。
*/
export class AuthorizationError extends Error {
/** 发起请求的操作者身份标识。 */
readonly actorId: string;
/** 执行该操作所需的角色。 */
readonly requiredRole: AuthRole;
/** 操作者实际持有的角色。 */
readonly actualRole: AuthRole;
/** 面向操作者的可读提示,固定包含"权限不足"。 */
readonly userMessage: string;
constructor(actorId: string, requiredRole: AuthRole, actualRole: AuthRole) {
const userMessage = `权限不足:操作需要「${requiredRole}」角色,当前操作者「${actorId}」角色为「${actualRole}`;
super(userMessage);
this.name = 'AuthorizationError';
this.actorId = actorId;
this.requiredRole = requiredRole;
this.actualRole = actualRole;
this.userMessage = userMessage;
// 维持 instanceof 在编译目标下正确工作。
Object.setPrototypeOf(this, AuthorizationError.prototype);
}
}
/**
* 不支持的视图角色错误(Req 13.1-13.3)。
*
* 当请求渲染角色化视图({@link ../views.renderView})所提供的视图角色不属于
* 商务/销售、风控、管理层任一受支持取值时抛出。`userMessage` 为面向用户的可读提示。
*
* 注:未分配角色用户的拒绝展示与"需分配角色"提示属表现层路由职责(Req 13.5,
* 见任务 15.6 `renderPortfolio`/路由守卫),与本错误关注的"取值非法"相区分。
*/
export class UnsupportedViewRoleError extends Error {
/** 传入的非法视图角色取值(以字符串呈现,便于诊断)。 */
readonly providedRole: string;
/** 受支持的视图角色取值列表。 */
readonly supportedRoles: readonly ViewRole[];
/** 面向用户的可读提示。 */
readonly userMessage: string;
constructor(providedRole: string, supportedRoles: readonly ViewRole[]) {
const userMessage = `不支持的视图角色「${providedRole}」,受支持的视图角色为:${supportedRoles.join('、')}`;
super(userMessage);
this.name = 'UnsupportedViewRoleError';
this.providedRole = providedRole;
this.supportedRoles = supportedRoles;
this.userMessage = userMessage;
Object.setPrototypeOf(this, UnsupportedViewRoleError.prototype);
}
}
/**
* 需分配角色错误(Req 13.5)。
*
* 当未分配商务/销售、风控或管理层任一角色的用户请求展示评估视图(含组合看板
* {@link ../renderPortfolio.renderPortfolio})时抛出,用以"拒绝展示评估视图并提示需分配角色"。
* `userMessage` 为面向用户的可读提示,固定包含"需分配角色",且本错误不携带任何评估数据,
* 从而保证无角色用户被一致拒绝且无法获得评估数据。
*/
export class RoleAssignmentRequiredError extends Error {
/** 面向用户的可读提示,固定包含"需分配角色"。 */
readonly userMessage: string;
constructor() {
const userMessage =
'需分配角色:当前用户未分配「商务/销售」「风控」或「管理层」任一角色,无法展示评估视图,请联系管理员分配角色后重试';
super(userMessage);
this.name = 'RoleAssignmentRequiredError';
this.userMessage = userMessage;
// 维持 instanceof 在编译目标下正确工作。
Object.setPrototypeOf(this, RoleAssignmentRequiredError.prototype);
}
}
+19
View File
@@ -0,0 +1,19 @@
/**
* RBAC 角色权限模块聚合导出(Req 12、13)。
*
* 本模块实现配置访问权限控制:定义配置访问角色(AuthRole)、`requireRole` 鉴权断言、
* 受守卫的配置修改入口 `applyConfigChange`(非 Administrator 一律拒绝且配置不变),
* 以及配置变更审计日志存储(变更提交 / 变更拒绝均留痕,精确到秒)。
*
* 并聚合导出角色化视图(视图角色 ViewRole、视图类型 View、`renderView`Req 13.1-13.3
* 与跨项目组合看板(`renderPortfolio`、无角色拒绝守卫,Req 13.4-13.6)。
*/
export * from './roles.js';
export * from './errors.js';
export * from './requireRole.js';
export * from './auditStore.js';
export * from './configChange.js';
export * from './views.js';
export * from './renderView.js';
export * from './renderPortfolio.js';
+177
View File
@@ -0,0 +1,177 @@
/**
* `renderPortfolio` 跨项目组合看板与无角色拒绝(RBAC 视图层,Req 13.4, 13.5, 13.6)。
*
* 面向管理层的组合看板:汇总给定 Assessment 集合中的**全部**评估,给出条目清单与
* 风险分级分布等高层概览(Req 13.4)。当集合为空时返回空组合看板并提示无可汇总的评估
* (Req 13.6);当请求用户未分配任一视图角色("无角色")时拒绝展示并提示分配角色,
* 且不返回任何评估数据(Req 13.5)。
*
* 角色维度复用 {@link ./views.ViewRole}(商务/销售、风控、管理层)。用户在视图维度上
* 还可能"无角色",故本模块以 {@link UserViewRole} = `ViewRole | '无角色'` 表达请求者角色,
* 并由 {@link requireAssignedRole} 守卫一致地拒绝无角色用户(与表现层路由守卫共用,Req 13.5、25.5)。
*/
import type {
BusinessType,
Industry,
RiskGrade,
RiskScore,
} from '../domain/common.js';
import { RISK_GRADE_VALUES } from '../domain/common.js';
import type { Acceptability, Assessment } from '../domain/assessment.js';
import { RoleAssignmentRequiredError } from './errors.js';
import type { ViewRole } from './views.js';
/** "无角色"取值:未分配任一视图角色的用户(Req 13.5)。 */
export const NO_ROLE = '无角色' as const;
/**
* 用户视图角色(UserViewRoleReq 13.4-13.5)。
*
* 在已分配视图角色({@link ViewRole}:商务/销售 / 风控 / 管理层)之外,
* 额外包含"无角色"以表达尚未分配任一角色的用户(Req 13.5)。
*/
export type UserViewRole = ViewRole | typeof NO_ROLE;
/**
* 判定用户是否已分配视图角色(即非"无角色"Req 13.5)。
*
* @param role 待判定的用户视图角色。
* @returns 当且仅当 `role` 为商务/销售、风控或管理层之一时返回 `true`。
*/
export function hasAssignedRole(role: UserViewRole): role is ViewRole {
return role !== NO_ROLE;
}
/**
* 视图展示前置守卫:要求用户已分配视图角色,否则拒绝并提示分配角色(Req 13.5)。
*
* 组合看板入口与表现层路由守卫均应前置调用本守卫,从而保证无角色用户被一致地拒绝、
* 且不会获得任何评估数据。
*
* @param role 发起展示请求的用户视图角色。
* @throws {RoleAssignmentRequiredError} 当 `role` 为"无角色"时。
*/
export function requireAssignedRole(role: UserViewRole): asserts role is ViewRole {
if (!hasAssignedRole(role)) {
throw new RoleAssignmentRequiredError();
}
}
/** 空组合看板的提示文案(Req 13.6)。 */
export const EMPTY_PORTFOLIO_PROMPT = '无可汇总的评估:当前没有可纳入组合看板的评估记录';
/**
* 组合看板单条评估摘要(Req 13.4)。
*
* 取自 Assessment 元数据,承载组合看板汇总所需的索引字段,便于跨项目对比与定位。
*/
export interface PortfolioEntry {
/** 评估唯一标识。 */
assessmentId: string;
/** 业务类型。 */
businessType: BusinessType;
/** 行业。 */
industry: Industry;
/** 风险总分。 */
riskScore: RiskScore;
/** 风险分级。 */
riskGrade: RiskGrade;
/** 可接受性结论(策略完成后产生,可能尚未生成)。 */
acceptability?: Acceptability;
/** 创建时间(ISO 8601 字符串)。 */
createdAt: string;
}
/**
* 风险分级分布:各 Risk_Grade 对应的评估计数(Req 13.4)。
*
* 键覆盖 RiskGrade 的全部取值(低/中/高/极高),各计数之和等于 `totalCount`。
*/
export type GradeDistribution = Record<RiskGrade, number>;
/**
* 跨项目组合看板视图(PortfolioViewReq 13.4, 13.6)。
*
* `entries` 一一对应输入集合中的**全部**评估且顺序一致(汇总全部评估,Req 13.4);
* 当输入集合为空时 `isEmpty` 为 `true` 且 `prompt` 给出无可汇总评估的提示(Req 13.6)。
*/
export interface PortfolioView {
/** 是否为空组合看板(输入集合为空时为 `true`,Req 13.6)。 */
isEmpty: boolean;
/** 纳入汇总的评估总数(等于 `entries.length`)。 */
totalCount: number;
/** 全部评估的摘要条目,顺序与输入集合一致。 */
entries: PortfolioEntry[];
/** 各风险分级的评估计数(各项之和等于 `totalCount`)。 */
gradeDistribution: GradeDistribution;
/** 空组合看板提示文案(仅当 `isEmpty` 为 `true` 时给出,Req 13.6)。 */
prompt?: string;
}
/** 构造各分级计数初始为 0 的分布表。 */
function emptyGradeDistribution(): GradeDistribution {
const distribution = {} as GradeDistribution;
for (const grade of RISK_GRADE_VALUES) {
distribution[grade] = 0;
}
return distribution;
}
/** 将单条评估映射为组合看板摘要条目(取自元数据索引字段)。 */
function toEntry(assessment: Assessment): PortfolioEntry {
const { metadata } = assessment;
return {
assessmentId: assessment.id,
businessType: metadata.businessType,
industry: metadata.industry,
riskScore: metadata.riskScore,
riskGrade: metadata.riskGrade,
createdAt: metadata.createdAt,
// 可接受性结论可能尚未生成;仅在存在时附带(exactOptionalPropertyTypes)。
...(assessment.acceptability !== undefined
? { acceptability: assessment.acceptability }
: {}),
};
}
/**
* 渲染跨项目组合看板(Req 13.4, 13.5, 13.6)。
*
* 行为:
* - 无角色用户(`role` 为"无角色")一律拒绝展示并提示分配角色,在汇总任何评估数据之前抛出,
* 故不返回任何评估数据(Req 13.5)。
* - 否则汇总 `assessments` 中的**全部**评估为组合看板:`entries` 与输入一一对应、顺序一致,
* 并按风险分级统计分布(Req 13.4)。
* - 当 `assessments` 为空集时,返回空组合看板(`isEmpty` 为 `true`)并附无可汇总评估的提示(Req 13.6)。
*
* 纯函数、确定性、无副作用,便于属性化测试约束(Property 48、49)。
*
* @param role 发起请求的用户视图角色。
* @param assessments 待汇总的评估集合(可为空)。
* @returns 组合看板视图。
* @throws {RoleAssignmentRequiredError} 当 `role` 为"无角色"时(Req 13.5)。
*/
export function renderPortfolio(
role: UserViewRole,
assessments: readonly Assessment[],
): PortfolioView {
// Req 13.5:无角色拒绝展示并提示分配角色(在汇总任何评估数据之前)。
requireAssignedRole(role);
const entries = assessments.map(toEntry);
const gradeDistribution = emptyGradeDistribution();
for (const entry of entries) {
gradeDistribution[entry.riskGrade] += 1;
}
const isEmpty = entries.length === 0;
return {
isEmpty,
totalCount: entries.length,
entries,
gradeDistribution,
// Req 13.6:空集→空看板并提示无可汇总的评估。
...(isEmpty ? { prompt: EMPTY_PORTFOLIO_PROMPT } : {}),
};
}
+138
View File
@@ -0,0 +1,138 @@
/**
* `renderView` 角色化视图渲染(ViewsReq 13.1-13.3)。
*
* 本模块实现 `renderView(role, assessment): View`:针对一个**已完成的 Assessment**
* 依视图角色({@link ViewRole})投影出与该角色职责匹配的视图:
*
* - 商务/销售({@link SalesView}Req 13.1):可接受性结论 + 接受条件清单 + 风险调整后报价。
* - 风控({@link RiskView}Req 13.2):评分项明细(Risk_Level / 判定依据 / Data_Provenance
* + 红线校验结果 + 信息缺口尽调事项。
* - 管理层({@link ManagementView}Req 13.3):Risk_Grade + 风险热力图 + Top N 关键风险
* + 利润对风险对比的高层看板。
*
* 设计要点:
* - 复用 {@link ../report/index.generate} 组装已完成 Assessment 的报告,再按角色投影对应子集,
* 从而与 Report_Generator、Scoring_Engine、Strategy_Engine 的产出保持一致,避免重复计算
* (热力图、Top N、接受条件、信息缺口均取自报告)。
* - 借助 `generate` 的流程门控:当评估流程(评分/红线/费用/可接受性结论)未完成时,
* `generate` 抛出 {@link ../report/errors.FlowNotCompleteError}`renderView` 不再额外重复校验,
* 保证"仅对已完成 Assessment 渲染视图"Req 13.1-13.3 前提)。
* - 纯函数、确定性、无副作用,便于属性化测试约束(Property 47)。
*/
import type { Assessment } from '../domain/assessment.js';
import type { CostEstimate } from '../domain/cost.js';
import { generate, type GenerateOptions } from '../report/index.js';
import { UnsupportedViewRoleError } from './errors.js';
import {
VIEW_ROLE_VALUES,
type ManagementView,
type RiskView,
type SalesView,
type View,
type ViewRole,
} from './views.js';
/** `renderView` 的可选项。 */
export interface RenderViewOptions {
/**
* Top N 关键风险的 N(透传给报告生成,仅管理层视图使用),可配置 1-50,默认 10(Req 7.2)。
*/
topN?: number;
}
/**
* 渲染角色化视图(Req 13.1-13.3)。
*
* 前置:`assessment` 的风险评分、红线校验、费用测算与可接受性结论均已完成,
* 否则由 {@link ../report/index.generate} 抛出
* {@link ../report/errors.FlowNotCompleteError}Req 10.4)。
*
* @param role 视图角色(商务/销售 / 风控 / 管理层)。
* @param assessment 已完成的评估记录。
* @param options 可选项:管理层视图的 Top N。
* @returns 与 `role` 匹配的角色化视图。
* @throws {UnsupportedViewRoleError} 当 `role` 不属于受支持的视图角色取值时。
* @throws {FlowNotCompleteError} 当评估流程任一前置步骤尚未完成时(经由 `generate`)。
*/
export function renderView(
role: ViewRole,
assessment: Assessment,
options: RenderViewOptions = {},
): View {
const generateOptions: GenerateOptions =
options.topN !== undefined ? { topN: options.topN } : {};
const report = generate(assessment, generateOptions);
switch (role) {
case '商务/销售':
return renderSalesView(assessment, report);
case '风控':
return renderRiskView(assessment, report);
case '管理层':
return renderManagementView(assessment, report);
default:
// 运行时防御:role 经类型约束应已穷尽,此处兜底非法取值。
throw new UnsupportedViewRoleError(String(role), VIEW_ROLE_VALUES);
}
}
/**
* 渲染商务/销售视图(Req 13.1)。
*
* 投影可接受性结论、接受条件清单(取自报告应对方案章节)与风险调整后报价
* (取自费用测算结果)。流程已完成时 `costEstimate` 必然存在(由 `generate` 门控保证)。
*/
function renderSalesView(assessment: Assessment, report: ReturnType<typeof generate>): SalesView {
const costEstimate = assessment.costEstimate as CostEstimate;
return {
role: '商务/销售',
acceptability: report.acceptabilityConclusion.acceptability,
acceptanceConditions: report.responsePlan.acceptanceConditions,
riskAdjustedQuote: costEstimate.riskAdjustedQuote,
baselineQuote: costEstimate.baselineQuote,
};
}
/**
* 渲染风控视图(Req 13.2)。
*
* 投影评分项明细(含 Risk_Level / 判定依据 / Data_Provenance)、红线校验结果与
* 信息缺口尽调事项(取值来源为"智能体假设"的评分项及其补充尽调建议)。
*/
function renderRiskView(assessment: Assessment, report: ReturnType<typeof generate>): RiskView {
return {
role: '风控',
scoringItems: [...assessment.scoringItems],
redlineResults: [...report.keyRisksAndRedlines.redlineResults],
informationGapDueDiligence: [...report.assumptionsAndGaps.items],
};
}
/**
* 渲染管理层看板视图(Req 13.3)。
*
* 投影 Risk_Grade、风险热力图、Top N 关键风险与利润对风险对比。利润对风险对比以
* 基准报价、风险调整后报价及二者之差(风险溢价)并置风险分级/总分,量化收益与风险敞口。
*/
function renderManagementView(
assessment: Assessment,
report: ReturnType<typeof generate>,
): ManagementView {
const costEstimate = assessment.costEstimate as CostEstimate;
const riskPremium = costEstimate.riskAdjustedQuote - costEstimate.baselineQuote;
return {
role: '管理层',
riskGrade: report.riskScoreGrade.riskGrade,
riskScore: report.riskScoreGrade.riskScore,
heatmap: [...report.heatmap.cells],
topKeyRisks: [...report.keyRisksAndRedlines.topKeyRisks],
profitVsRisk: {
riskGrade: report.riskScoreGrade.riskGrade,
riskScore: report.riskScoreGrade.riskScore,
baselineQuote: costEstimate.baselineQuote,
riskAdjustedQuote: costEstimate.riskAdjustedQuote,
riskPremium,
},
};
}
+39
View File
@@ -0,0 +1,39 @@
/**
* `requireRole` 角色鉴权断言(RBACReq 12.1, 12.3)。
*
* 写操作前置守卫:校验操作者是否具备所需角色,不满足则抛出
* {@link AuthorizationError}(权限不足)。Config_Center 的全部配置写操作均应前置
* `requireRole('Administrator', actor)`,从而保证仅 Administrator 可修改配置。
*/
import { AuthorizationError } from './errors.js';
import type { Actor, AuthRole } from './roles.js';
/**
* 要求操作者具备指定角色,否则抛出权限不足错误(Req 12.1, 12.3)。
*
* 角色匹配采用精确相等:仅当 `actor.role === requiredRole` 时通过。
* 因此当 `requiredRole` 为 `'Administrator'` 时,任何非 Administrator 操作者
* (含 Assessor、未认证、未授权)均会被拒绝(Req 12.1, 12.3)。
*
* @param requiredRole 执行操作所需的角色。
* @param actor 发起操作的操作者。
* @throws {AuthorizationError} 当 `actor.role` 不等于 `requiredRole` 时。
*/
export function requireRole(requiredRole: AuthRole, actor: Actor): void {
if (actor.role !== requiredRole) {
throw new AuthorizationError(actor.id, requiredRole, actor.role);
}
}
/**
* 判定操作者是否被授权执行配置写操作(即角色是否为 Administrator)。
*
* 不抛出异常的非破坏式判定,便于上层在审计/分支逻辑中先行检查。
*
* @param actor 发起操作的操作者。
* @returns 当且仅当 `actor.role` 为 `'Administrator'` 时返回 `true`。
*/
export function canModifyConfig(actor: Actor): boolean {
return actor.role === 'Administrator';
}
+50
View File
@@ -0,0 +1,50 @@
/**
* 配置访问控制角色(RBAC,Req 12)。
*
* 本模块定义"配置修改权限"维度上的操作者角色,用于 Config_Center 写操作的鉴权。
* 与表现层的视图角色(商务/销售、风控、管理层,见任务 15.4 `renderView`)相互独立:
* 此处关注"谁可以修改 Risk_Model 配置",唯一具备写权限的角色为 AdministratorReq 11、12)。
*/
/**
* 配置访问角色(AuthRole)。
*
* - Administrator:管理员,唯一可修改 Risk_Model 配置的角色(Req 11、12.1)。
* - Assessor:评估者(含商务/销售、风控、管理层),可使用但不可修改配置(Req 12.1, 12.2)。
* - 未认证:未完成身份认证的操作者(Req 12.3)。
* - 未授权:已认证但未被授予任何有效角色的操作者(Req 12.3)。
*
* 其中 Assessor、未认证、未授权统称"非 Administrator",其配置修改请求一律拒绝(Req 12.1, 12.3)。
*/
export type AuthRole = 'Administrator' | 'Assessor' | '未认证' | '未授权';
/** AuthRole 的全部取值,便于运行时校验与遍历。 */
export const AUTH_ROLE_VALUES = [
'Administrator',
'Assessor',
'未认证',
'未授权',
] as const;
/**
* 操作者(Actor)。
*
* 承载发起配置操作的身份标识与其配置访问角色,贯穿鉴权与审计:
* `id` 记入审计日志的操作者身份(Req 12.4, 12.5)。
*/
export interface Actor {
/** 操作者身份标识(记入审计日志)。 */
id: string;
/** 操作者的配置访问角色。 */
role: AuthRole;
}
/**
* 判定操作者角色是否为 Administrator(唯一可修改配置的角色,Req 11、12.1)。
*
* @param role 待判定的配置访问角色。
* @returns 当且仅当 `role` 为 `'Administrator'` 时返回 `true`。
*/
export function isAdministrator(role: AuthRole): boolean {
return role === 'Administrator';
}
+123
View File
@@ -0,0 +1,123 @@
/**
* 角色化视图类型与视图角色(ViewsReq 13.1-13.3)。
*
* 本模块定义表现层的**视图角色**(商务/销售、风控、管理层)及三类角色化视图的结构。
*
* 视图角色({@link ViewRole})与配置访问角色({@link ./roles.AuthRole})是相互独立的两个维度:
* - AuthRole 关注"谁可以修改 Risk_Model 配置"(唯一写权限为 AdministratorReq 12)。
* - ViewRole 关注"以何种视角呈现一个已完成的 Assessment"(差异化交互与视图,Req 13)。
*
* 一个 Assessor(评估者)在配置访问维度上不可修改配置,但在视图维度上可属于商务/销售、
* 风控或管理层之一,从而获得与其职责匹配的角色化视图(Req 12.2、Req 13.1-13.3)。
*/
import type { RiskGrade, RiskScore, Money } from '../domain/common.js';
import type {
Acceptability,
HeatmapCell,
RedlineResult,
RiskItem,
ScoringItem,
} from '../domain/assessment.js';
import type { AcceptanceCondition } from '../strategy/index.js';
import type { AssumptionGapItem } from '../report/index.js';
/**
* 视图角色(ViewRoleReq 13.1-13.3)。
*
* 面向已完成 Assessment 的三类业务视角:
* - 商务/销售:关注可接受性结论、接受条件与对外报价(Req 13.1)。
* - 风控:关注评分明细、红线校验与信息缺口尽调(Req 13.2)。
* - 管理层:关注分级、热力图、Top N 关键风险与利润对风险对比的高层看板(Req 13.3)。
*/
export type ViewRole = '商务/销售' | '风控' | '管理层';
/** ViewRole 的全部取值,便于运行时校验与遍历。 */
export const VIEW_ROLE_VALUES = ['商务/销售', '风控', '管理层'] as const;
/**
* 商务/销售视图(SalesViewReq 13.1)。
*
* 包含可接受性结论、接受条件清单与风险调整后报价,支撑商务/销售对外谈判与报价决策。
*/
export interface SalesView {
/** 视图角色判别标记。 */
role: '商务/销售';
/** 可接受性结论(可接受/有条件接受/不可接受,Req 9.1)。 */
acceptability: Acceptability;
/**
* 接受条件清单(Req 9.2, 9.5)。
* 仅当结论为"有条件接受"时非空;每条关联≥1 关键风险并附成本影响测算。
*/
acceptanceConditions: AcceptanceCondition[];
/** 风险调整后报价(恒不低于基准报价,Req 8.3)。 */
riskAdjustedQuote: Money;
/** 基准报价(供商务对照风险调整后报价的加价幅度)。 */
baselineQuote: Money;
}
/**
* 风控视图(RiskViewReq 13.2)。
*
* 包含评分项明细(含 Risk_Level、判定依据、Data_Provenance)、红线校验结果与
* 信息缺口尽调事项,支撑风控复核评分依据与补充尽调。
*/
export interface RiskView {
/** 视图角色判别标记。 */
role: '风控';
/** 评分项明细:每项含 Risk_Level、判定依据、Data_Provenance、置信度等(Req 13.2, 18.1-18.4)。 */
scoringItems: ScoringItem[];
/** 红线校验结果集合(命中/未命中/待核实,Req 6.1, 6.3, 6.5)。 */
redlineResults: RedlineResult[];
/**
* 信息缺口尽调事项(Req 13.2, 18.5, 18.6)。
* 取值来源为"智能体假设"的评分项清单,每项附关联对应 Indicator 的补充尽调建议。
*/
informationGapDueDiligence: AssumptionGapItem[];
}
/**
* 利润对风险对比(ProfitVsRiskReq 13.3)。
*
* 将本次评估的风险水平(分级与总分)与对外报价的利润口径并置呈现,
* 供管理层权衡承接收益与风险敞口。
*/
export interface ProfitVsRisk {
/** 风险分级(低/中/高/极高)。 */
riskGrade: RiskGrade;
/** 风险总分(0-100)。 */
riskScore: RiskScore;
/** 基准报价。 */
baselineQuote: Money;
/** 风险调整后报价(恒不低于基准报价,Req 8.3)。 */
riskAdjustedQuote: Money;
/** 风险溢价:风险调整后报价与基准报价之差(= riskAdjustedQuote baselineQuote,恒非负)。 */
riskPremium: Money;
}
/**
* 管理层看板视图(ManagementViewReq 13.3)。
*
* 高层看板:包含 Risk_Grade、风险热力图、Top N 关键风险与利润对风险对比。
*/
export interface ManagementView {
/** 视图角色判别标记。 */
role: '管理层';
/** 风险分级(低/中/高/极高,Req 5)。 */
riskGrade: RiskGrade;
/** 风险总分(0-100)。 */
riskScore: RiskScore;
/** 风险热力图单元格(Dimension 行 × Indicator 列 × Risk_Level 严重度,Req 7.1)。 */
heatmap: HeatmapCell[];
/** Top N 关键风险清单(得分降序,Req 7.2-7.5)。 */
topKeyRisks: RiskItem[];
/** 利润对风险对比(Req 13.3)。 */
profitVsRisk: ProfitVsRisk;
}
/**
* 角色化视图(ViewReq 13.1-13.3)。
*
* 以 `role` 字段为判别标记的可辨识联合,按视图角色映射到对应的视图结构。
*/
export type View = SalesView | RiskView | ManagementView;