Archived
M1: 底座 —— @contracts 首版 + event / ledger / org / llm
@contracts 首版(§8.1 九个文件): - id 品牌 ID / terminal 终态五名与归侧四标签 / envelope 信封六字段与四类事件 - card 工单两模式 + 对话卡族(形状对齐 2026-07-26 原型定稿) - contract7 契约七件 / ledger 三级寻址与写三分 / process-package 流程包 DSL 与触发器三类 - manifest 活动包清单六区(含 CMMN·DMN·步序声明的资产形状)/ org / api 路由契约 模块(L1): - event 信封存储、归属解析三级短路、投递五诺、监听注册表、三级消费裁定 - ledger schema 自生长(最大化合并/最宽类型/只增不破坏)、任务视图叠加、写三分、冲突检查 - org 岗位授权(客体三类×动作三种)、停用前置检查、会话、种子播种 - llm 四元绑定、ModelGateway 三模式(live/record/replay)—— 两缝之一 app 装配:Hono 服务 + 鉴权中间件 + 治理台/工作台路由;drizzle 迁移;AES-256-GCM 凭证密封 ADR-007 工具链与门禁 · ADR-008 投递队列 · ADR-009 凭证加密 (ADR 编号自 007 续:编码规范 §14 已用到 006,README 写「从 005 续」是笔误) 门禁五道全绿;96 个测试通过,含对真实 PG 的 ledger 集成测试 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
co-authored by
Claude Opus 5
parent
7cd664af3a
commit
14d4fa046a
@@ -0,0 +1,33 @@
|
||||
# module: org
|
||||
|
||||
职责: 员工与岗位:账号、岗位授权(客体三类 × 动作三种)、停用前置检查、会话装载、
|
||||
岗位在岗人员查询(负责人四策略解析的候选集来源)、种子数据播种
|
||||
不负责: 负责人解析算法本身(见 process)、任务与卡的在途判定(经 InFlightWorkProbe 端口由 app 注入)、
|
||||
鉴权中间件与 cookie 会话(app 层)
|
||||
|
||||
公开出口: index.ts
|
||||
- 类型:`OrgService` `StaffWithPosition` `CreateStaffInput` 与 @contracts 的 `Staff` / `Position` /
|
||||
`Grant` / `GrantObject` / `GrantAction` / `DisableCheck` / `Session` 转出
|
||||
- 错误:`StaffNotFoundError` `PositionNotFoundError` `AccountTakenError` `DisableBlockedError`
|
||||
- 端口类型:`PositionRepository` `StaffRepository` `GrantRepository` `InFlightWorkProbe`
|
||||
`Clock` `IdGenerator`
|
||||
- 用例工厂:`createOrgService(deps: OrgServiceDeps): OrgService`
|
||||
- 纯函数:`isAuthorized` `actionCovers` `objectCovers`
|
||||
- 常量:`POSITION_SEED` `STAFF_SEED` `DEFAULT_LOGIN_ACCOUNT`
|
||||
- adapter 工厂(供 app 装配):`createPositionRepository` `createStaffRepository`
|
||||
`createGrantRepository` `objectKeyOf`
|
||||
- Drizzle 表(供 app 迁移与 join):`positionTable` `staffTable` `grantTable`
|
||||
|
||||
层级: L1(编码规范 §4)
|
||||
依赖: @contracts;@shared/db(Drizzle 句柄与 sys schema)
|
||||
port:`InFlightWorkProbe`(app 注入 task / human-tool 的只读用例)、`IdGenerator`
|
||||
被依赖: process(负责人解析的候选集)、message、interaction、human-tool、report、
|
||||
app(鉴权中间件与 /api/gov/staff 路由组装)
|
||||
|
||||
不变量:
|
||||
- 动作蕴含:治理 ⊃ 写 ⊃ 读;数据客体三级覆盖(域 ⊃ 对象 ⊃ 字段)
|
||||
- 停用是全有全无:前置检查未过即抛 `DisableBlockedError`,不做部分执行
|
||||
- `authorize` 对停用员工恒 false
|
||||
- `ensureSeeded` 幂等:按 code / account 判存在,已存在则跳过,不覆盖人工改动
|
||||
|
||||
影响半径: index/contract/port 未变 → 对外零影响
|
||||
@@ -0,0 +1,92 @@
|
||||
/** domain 测试零 mock(编码规范 §16) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Grant, PositionId } from '@contracts';
|
||||
import { actionCovers, isAuthorized, objectCovers } from '../internal/domain/authorization.ts';
|
||||
|
||||
const pos = 'p-bd' as PositionId;
|
||||
|
||||
describe('actionCovers —— 动作蕴含', () => {
|
||||
it('治理蕴含写与读', () => {
|
||||
expect(actionCovers('govern', 'write')).toBe(true);
|
||||
expect(actionCovers('govern', 'read')).toBe(true);
|
||||
});
|
||||
it('写蕴含读,但读不蕴含写', () => {
|
||||
expect(actionCovers('write', 'read')).toBe(true);
|
||||
expect(actionCovers('read', 'write')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectCovers —— 客体三级覆盖', () => {
|
||||
it('对象级授权覆盖其下字段', () => {
|
||||
expect(
|
||||
objectCovers(
|
||||
{ kind: 'data', domain: 'shared', object: 'customer' },
|
||||
{ kind: 'data', domain: 'shared', object: 'customer', field: 'grade' },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('字段级授权不覆盖别的字段', () => {
|
||||
expect(
|
||||
objectCovers(
|
||||
{ kind: 'data', domain: 'shared', object: 'customer', field: 'grade' },
|
||||
{ kind: 'data', domain: 'shared', object: 'customer', field: 'payment_terms' },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('域级通配覆盖域内全部对象', () => {
|
||||
expect(
|
||||
objectCovers(
|
||||
{ kind: 'data', domain: 'shared', object: '*' },
|
||||
{ kind: 'data', domain: 'shared', object: 'supplier', field: 'contact' },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('跨域不覆盖', () => {
|
||||
expect(
|
||||
objectCovers(
|
||||
{ kind: 'data', domain: 'shared', object: '*' },
|
||||
{ kind: 'data', domain: 'quote_intake', object: 'record' },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('治理功能逐项授权', () => {
|
||||
expect(objectCovers({ kind: 'governance', function: 'task' }, { kind: 'governance', function: 'task' })).toBe(true);
|
||||
expect(objectCovers({ kind: 'governance', function: 'task' }, { kind: 'governance', function: 'staff' })).toBe(false);
|
||||
});
|
||||
|
||||
it('任务客体未限定活动即覆盖全部任务', () => {
|
||||
expect(objectCovers({ kind: 'task' }, { kind: 'task', activityId: 'a1' })).toBe(true);
|
||||
expect(objectCovers({ kind: 'task', activityId: 'a1' }, { kind: 'task', activityId: 'a2' })).toBe(false);
|
||||
});
|
||||
|
||||
it('客体类型不同一律不覆盖', () => {
|
||||
expect(objectCovers({ kind: 'task' }, { kind: 'governance', function: 'task' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthorized —— 任一授权覆盖即通过', () => {
|
||||
const grants: Grant[] = [
|
||||
{ positionId: pos, object: { kind: 'governance', function: 'task' }, action: 'read' },
|
||||
{ positionId: pos, object: { kind: 'data', domain: 'shared', object: '*' }, action: 'write' },
|
||||
];
|
||||
|
||||
it('命中数据域的写', () => {
|
||||
expect(isAuthorized(grants, { kind: 'data', domain: 'shared', object: 'customer' }, 'write')).toBe(true);
|
||||
});
|
||||
|
||||
it('写权蕴含读权', () => {
|
||||
expect(isAuthorized(grants, { kind: 'data', domain: 'shared', object: 'customer' }, 'read')).toBe(true);
|
||||
});
|
||||
|
||||
it('只读的治理功能不得写', () => {
|
||||
expect(isAuthorized(grants, { kind: 'governance', function: 'task' }, 'govern')).toBe(false);
|
||||
});
|
||||
|
||||
it('空授权一律不通过', () => {
|
||||
expect(isAuthorized([], { kind: 'governance', function: 'system' }, 'read')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* module: org —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。
|
||||
*/
|
||||
import type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, StaffId } from '@contracts';
|
||||
|
||||
export type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff };
|
||||
|
||||
/** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */
|
||||
export type StaffWithPosition = Staff & { readonly position: Position };
|
||||
|
||||
export type CreateStaffInput = {
|
||||
readonly account: string;
|
||||
readonly name: string;
|
||||
readonly positionId: string;
|
||||
};
|
||||
|
||||
export class StaffNotFoundError extends Error {
|
||||
constructor(readonly staffId: StaffId) {
|
||||
super(`员工不存在:${staffId}`);
|
||||
this.name = 'StaffNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PositionNotFoundError extends Error {
|
||||
constructor(readonly positionCode: string) {
|
||||
super(`岗位不存在:${positionCode}`);
|
||||
this.name = 'PositionNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountTakenError extends Error {
|
||||
constructor(readonly account: string) {
|
||||
super(`账号已存在:${account}`);
|
||||
this.name = 'AccountTakenError';
|
||||
}
|
||||
}
|
||||
|
||||
/** 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02) */
|
||||
export class DisableBlockedError extends Error {
|
||||
constructor(readonly check: DisableCheck) {
|
||||
super(`员工停用前置检查未过:在途任务 ${check.openTasks.length} 件、待答卡 ${check.openCards.length} 张`);
|
||||
this.name = 'DisableBlockedError';
|
||||
}
|
||||
}
|
||||
|
||||
export type OrgService = {
|
||||
listStaff(): Promise<{ staff: StaffWithPosition[]; positions: Position[] }>;
|
||||
getStaff(staffId: StaffId): Promise<StaffWithPosition>;
|
||||
findByAccount(account: string): Promise<StaffWithPosition | undefined>;
|
||||
createStaff(input: CreateStaffInput): Promise<Staff>;
|
||||
/** 停用前置检查 —— 只读,治理台先看再点 */
|
||||
checkDisable(staffId: StaffId): Promise<DisableCheck>;
|
||||
/** 停用 —— 前置检查不过即抛 DisableBlockedError,不做部分执行 */
|
||||
disableStaff(staffId: StaffId): Promise<void>;
|
||||
listGrants(positionId: string): Promise<Grant[]>;
|
||||
setGrants(positionId: string, grants: readonly Grant[]): Promise<Grant[]>;
|
||||
/** 岗位权限校验 —— app 层鉴权中间件读它 */
|
||||
authorize(staffId: StaffId, object: GrantObject, action: GrantAction): Promise<boolean>;
|
||||
loadSession(account: string): Promise<Session | undefined>;
|
||||
/** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用) */
|
||||
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
|
||||
ensureSeeded(): Promise<void>;
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type {
|
||||
CreateStaffInput,
|
||||
DisableCheck,
|
||||
Grant,
|
||||
GrantAction,
|
||||
GrantObject,
|
||||
OrgService,
|
||||
Position,
|
||||
Session,
|
||||
Staff,
|
||||
StaffWithPosition,
|
||||
} from './contract.ts';
|
||||
export {
|
||||
AccountTakenError,
|
||||
DisableBlockedError,
|
||||
PositionNotFoundError,
|
||||
StaffNotFoundError,
|
||||
} from './contract.ts';
|
||||
|
||||
export type {
|
||||
Clock,
|
||||
GrantRepository,
|
||||
IdGenerator,
|
||||
InFlightWorkProbe,
|
||||
PositionRepository,
|
||||
StaffRepository,
|
||||
} from './port.ts';
|
||||
|
||||
export type { OrgServiceDeps } from './internal/usecase/org-service.ts';
|
||||
export { createOrgService } from './internal/usecase/org-service.ts';
|
||||
|
||||
export { isAuthorized, actionCovers, objectCovers } from './internal/domain/authorization.ts';
|
||||
export { DEFAULT_LOGIN_ACCOUNT, POSITION_SEED, STAFF_SEED } from './internal/domain/seed-data.ts';
|
||||
|
||||
export {
|
||||
createGrantRepository,
|
||||
createPositionRepository,
|
||||
createStaffRepository,
|
||||
objectKeyOf,
|
||||
} from './internal/adapter/org-repository.ts';
|
||||
export { grantTable, positionTable, staffTable } from './internal/adapter/org-schema.ts';
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* org 的仓储 adapter —— Drizzle 只准出现在 adapter 与 app(编码规范 §5.3 / §11.4)。
|
||||
*/
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import type { Grant, GrantObject, Position, PositionId, Staff, StaffId } from '@contracts';
|
||||
import { grantSchema, positionIdSchema, positionSchema, staffSchema } from '@contracts';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import type { GrantRepository, PositionRepository, StaffRepository } from '../../port.ts';
|
||||
import { grantTable, positionTable, staffTable } from './org-schema.ts';
|
||||
|
||||
/** 授权客体的规范化串 —— 主键的一部分,保证同一客体不重复授权 */
|
||||
export function objectKeyOf(object: GrantObject): string {
|
||||
switch (object.kind) {
|
||||
case 'data':
|
||||
return `data:${object.domain}.${object.object}${object.field === undefined ? '' : `.${object.field}`}`;
|
||||
case 'governance':
|
||||
return `governance:${object.function}`;
|
||||
case 'task':
|
||||
return `task${object.activityId === undefined ? '' : `:${object.activityId}`}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createPositionRepository(db: Db): PositionRepository {
|
||||
const toPosition = (row: typeof positionTable.$inferSelect): Position =>
|
||||
positionSchema.parse({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
...(row.note === null ? {} : { note: row.note }),
|
||||
});
|
||||
|
||||
return {
|
||||
async list() {
|
||||
const rows = await db.select().from(positionTable).orderBy(positionTable.code);
|
||||
return rows.map(toPosition);
|
||||
},
|
||||
async findByCode(code) {
|
||||
const rows = await db.select().from(positionTable).where(eq(positionTable.code, code)).limit(1);
|
||||
return rows[0] === undefined ? undefined : toPosition(rows[0]);
|
||||
},
|
||||
async findById(id) {
|
||||
const rows = await db.select().from(positionTable).where(eq(positionTable.id, id)).limit(1);
|
||||
return rows[0] === undefined ? undefined : toPosition(rows[0]);
|
||||
},
|
||||
async upsert(position) {
|
||||
const id = position.id ?? positionIdSchema.parse(position.code.toLowerCase());
|
||||
const [row] = await db
|
||||
.insert(positionTable)
|
||||
.values({ id, code: position.code, name: position.name, note: position.note ?? null })
|
||||
.onConflictDoUpdate({
|
||||
target: positionTable.code,
|
||||
set: { name: position.name, note: position.note ?? null },
|
||||
})
|
||||
.returning();
|
||||
if (row === undefined) throw new Error('岗位写入未返回行');
|
||||
return toPosition(row);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createStaffRepository(db: Db): StaffRepository {
|
||||
const toStaff = (row: typeof staffTable.$inferSelect): Staff =>
|
||||
staffSchema.parse({
|
||||
id: row.id,
|
||||
account: row.account,
|
||||
name: row.name,
|
||||
positionId: row.positionId,
|
||||
state: row.state,
|
||||
});
|
||||
|
||||
return {
|
||||
async list() {
|
||||
const rows = await db.select().from(staffTable).orderBy(staffTable.account);
|
||||
return rows.map(toStaff);
|
||||
},
|
||||
async findById(id: StaffId) {
|
||||
const rows = await db.select().from(staffTable).where(eq(staffTable.id, id)).limit(1);
|
||||
return rows[0] === undefined ? undefined : toStaff(rows[0]);
|
||||
},
|
||||
async findByAccount(account: string) {
|
||||
const rows = await db.select().from(staffTable).where(eq(staffTable.account, account)).limit(1);
|
||||
return rows[0] === undefined ? undefined : toStaff(rows[0]);
|
||||
},
|
||||
async listByPosition(positionId: PositionId) {
|
||||
const rows = await db.select().from(staffTable).where(eq(staffTable.positionId, positionId));
|
||||
return rows.map(toStaff);
|
||||
},
|
||||
async insert(staff: Staff) {
|
||||
const [row] = await db.insert(staffTable).values(staff).returning();
|
||||
if (row === undefined) throw new Error('员工写入未返回行');
|
||||
return toStaff(row);
|
||||
},
|
||||
async setState(id: StaffId, state) {
|
||||
await db.update(staffTable).set({ state }).where(eq(staffTable.id, id));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createGrantRepository(db: Db): GrantRepository {
|
||||
const toGrant = (row: typeof grantTable.$inferSelect): Grant =>
|
||||
grantSchema.parse({ positionId: row.positionId, object: row.object, action: row.action });
|
||||
|
||||
return {
|
||||
async listByPosition(positionId: PositionId) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(grantTable)
|
||||
.where(and(eq(grantTable.positionId, positionId), eq(grantTable.enabled, true)));
|
||||
return rows.map(toGrant);
|
||||
},
|
||||
async replaceForPosition(positionId: PositionId, grants) {
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.delete(grantTable).where(eq(grantTable.positionId, positionId));
|
||||
if (grants.length === 0) return [];
|
||||
const rows = await tx
|
||||
.insert(grantTable)
|
||||
.values(
|
||||
grants.map((g) => ({
|
||||
positionId,
|
||||
objectKey: objectKeyOf(g.object),
|
||||
action: g.action,
|
||||
object: g.object,
|
||||
enabled: true,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
return rows.map(toGrant);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* org 的 sys schema 表(编码规范 §7 / ADR-002:sys 由各模块 adapter 经 Drizzle 单写)。
|
||||
* drizzle-kit 以 glob 收集本仓全部 `*-schema.ts`(见 drizzle.config.ts)。
|
||||
*/
|
||||
import { boolean, index, jsonb, primaryKey, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sys } from '@shared/db.ts';
|
||||
|
||||
export const positionTable = sys.table(
|
||||
'position',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
code: text('code').notNull(),
|
||||
name: text('name').notNull(),
|
||||
note: text('note'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [uniqueIndex('position_code_key').on(t.code)],
|
||||
);
|
||||
|
||||
export const staffTable = sys.table(
|
||||
'staff',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
account: text('account').notNull(),
|
||||
name: text('name').notNull(),
|
||||
positionId: text('position_id')
|
||||
.notNull()
|
||||
.references(() => positionTable.id),
|
||||
state: text('state').notNull().default('active'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [uniqueIndex('staff_account_key').on(t.account), index('staff_position_idx').on(t.positionId)],
|
||||
);
|
||||
|
||||
export const grantTable = sys.table(
|
||||
'position_grant',
|
||||
{
|
||||
positionId: text('position_id')
|
||||
.notNull()
|
||||
.references(() => positionTable.id, { onDelete: 'cascade' }),
|
||||
/** 客体的规范化串:data:<域>.<对象>[.<字段>] / governance:<功能> / task[:<活动>] */
|
||||
objectKey: text('object_key').notNull(),
|
||||
action: text('action').notNull(),
|
||||
object: jsonb('object').notNull(),
|
||||
enabled: boolean('enabled').notNull().default(true),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.positionId, t.objectKey, t.action] })],
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 岗位授权判定 —— 纯逻辑,零 IO(编码规范 §5.3)。
|
||||
*
|
||||
* 权限按岗位授予:客体三类(数据 域.对象[.字段] / 治理功能 / 任务)× 动作三种(读 / 写 / 治理)。
|
||||
* 语义出处:OS 02 员工管理。
|
||||
*/
|
||||
import type { Grant, GrantAction, GrantObject } from '@contracts';
|
||||
|
||||
/** 动作蕴含:治理 ⊃ 写 ⊃ 读 —— 有写权即可读,有治理权即可写 */
|
||||
const IMPLIES: Readonly<Record<GrantAction, readonly GrantAction[]>> = {
|
||||
govern: ['govern', 'write', 'read'],
|
||||
write: ['write', 'read'],
|
||||
read: ['read'],
|
||||
};
|
||||
|
||||
export function actionCovers(granted: GrantAction, required: GrantAction): boolean {
|
||||
return IMPLIES[granted].includes(required);
|
||||
}
|
||||
|
||||
/**
|
||||
* 客体覆盖:授权客体是否覆盖被请求客体。
|
||||
* 数据客体分三级:域.对象.字段 —— 授到域即覆盖其下全部对象,授到对象即覆盖其下全部字段。
|
||||
*/
|
||||
export function objectCovers(granted: GrantObject, required: GrantObject): boolean {
|
||||
if (granted.kind !== required.kind) return false;
|
||||
|
||||
switch (granted.kind) {
|
||||
case 'data': {
|
||||
const req = required as Extract<GrantObject, { kind: 'data' }>;
|
||||
if (granted.domain !== req.domain) return false;
|
||||
// 授权到域级(未指定对象名的通配 '*')
|
||||
if (granted.object !== '*' && granted.object !== req.object) return false;
|
||||
if (granted.field === undefined) return true;
|
||||
return granted.field === req.field;
|
||||
}
|
||||
case 'governance': {
|
||||
const req = required as Extract<GrantObject, { kind: 'governance' }>;
|
||||
return granted.function === req.function;
|
||||
}
|
||||
case 'task': {
|
||||
const req = required as Extract<GrantObject, { kind: 'task' }>;
|
||||
// 未限定活动 = 覆盖全部任务
|
||||
if (granted.activityId === undefined) return true;
|
||||
return granted.activityId === req.activityId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 任一授权覆盖即通过 */
|
||||
export function isAuthorized(
|
||||
grants: readonly Grant[],
|
||||
object: GrantObject,
|
||||
action: GrantAction,
|
||||
): boolean {
|
||||
return grants.some((g) => actionCovers(g.action, action) && objectCovers(g.object, object));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 停用前置检查 —— 纯逻辑,零 IO(编码规范 §5.3)。
|
||||
*
|
||||
* OS 02:员工停用前,其在途任务与待答卡须先改派完毕。
|
||||
*/
|
||||
import type { DisableCheck, StaffId } from '@contracts';
|
||||
|
||||
export type InFlightWork = {
|
||||
readonly openTasks: ReadonlyArray<{ taskId: string; title: string }>;
|
||||
readonly openCards: ReadonlyArray<{ cardId: string; title: string }>;
|
||||
readonly openApprovals: ReadonlyArray<{ approvalId: string; title: string }>;
|
||||
};
|
||||
|
||||
export function buildDisableCheck(staffId: StaffId, work: InFlightWork): DisableCheck {
|
||||
const openTasks = [...work.openTasks];
|
||||
const openCards = [...work.openCards];
|
||||
const openApprovals = [...work.openApprovals];
|
||||
return {
|
||||
staffId,
|
||||
blocked: openTasks.length > 0 || openCards.length > 0 || openApprovals.length > 0,
|
||||
openTasks,
|
||||
openCards,
|
||||
openApprovals,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 种子数据(README《种子数据》)—— 纯常量,零 IO。
|
||||
* 部署边界:单次部署 ≤50 人、单组织、不做多租户(OS 0.3)。
|
||||
*/
|
||||
|
||||
export const POSITION_SEED = [
|
||||
{ code: 'PM', name: '项目经理', note: '定价审批、策略拍板' },
|
||||
{ code: 'BD', name: '商务', note: 'RFQ 受理、成本定价、报价议价、情报归档' },
|
||||
{ code: 'FAE', name: '现场应用', note: '替代选型' },
|
||||
{ code: 'PRM', name: '采购/寻源', note: '渠道寻源' },
|
||||
{ code: 'OP', name: '运营', note: '运营支持' },
|
||||
] as const;
|
||||
|
||||
export const STAFF_SEED = [
|
||||
{ account: 'dingziqiao', name: '丁子乔', position: 'PM' },
|
||||
{ account: 'fengbing', name: '冯兵', position: 'BD' },
|
||||
{ account: 'wangziming', name: '王梓名', position: 'FAE' },
|
||||
{ account: 'linpeixuan', name: '林佩萱', position: 'PRM' },
|
||||
{ account: 'liyuanchu', name: '李元初', position: 'OP' },
|
||||
] as const;
|
||||
|
||||
/** 登录者 = 冯兵(BD);MVP 单组织 cookie session(编码规范 §4 鉴权定调) */
|
||||
export const DEFAULT_LOGIN_ACCOUNT = 'fengbing';
|
||||
|
||||
export type PositionSeed = (typeof POSITION_SEED)[number];
|
||||
export type StaffSeed = (typeof STAFF_SEED)[number];
|
||||
|
||||
/**
|
||||
* 岗位默认授权 —— 治理台九功能对 PM 全开,其余岗位只开与其职责相关的读。
|
||||
* 任务负责人与被征答人对所涉内容**天然可见**,不靠这里授权(OS 02)。
|
||||
*/
|
||||
export const POSITION_GRANT_SEED: Readonly<Record<string, ReadonlyArray<{ fn: string; action: 'read' | 'write' | 'govern' }>>> = {
|
||||
PM: [
|
||||
{ fn: 'task', action: 'govern' },
|
||||
{ fn: 'staff', action: 'govern' },
|
||||
{ fn: 'process', action: 'govern' },
|
||||
{ fn: 'trigger', action: 'govern' },
|
||||
{ fn: 'activity', action: 'govern' },
|
||||
{ fn: 'tool', action: 'govern' },
|
||||
{ fn: 'data', action: 'govern' },
|
||||
{ fn: 'memory', action: 'govern' },
|
||||
{ fn: 'system', action: 'govern' },
|
||||
],
|
||||
BD: [
|
||||
{ fn: 'task', action: 'read' },
|
||||
{ fn: 'data', action: 'read' },
|
||||
],
|
||||
FAE: [
|
||||
{ fn: 'task', action: 'read' },
|
||||
{ fn: 'data', action: 'read' },
|
||||
],
|
||||
PRM: [
|
||||
{ fn: 'task', action: 'read' },
|
||||
{ fn: 'data', action: 'read' },
|
||||
],
|
||||
OP: [
|
||||
{ fn: 'task', action: 'read' },
|
||||
{ fn: 'data', action: 'read' },
|
||||
{ fn: 'memory', action: 'read' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* org 用例 —— 编排 domain,依赖 port.ts 声明的能力,不 import 任何 adapter(编码规范 §5.3)。
|
||||
*/
|
||||
import type { Grant, GrantAction, GrantObject, Position, PositionId, Session, Staff, StaffId } from '@contracts';
|
||||
import { governanceFunctionSchema, positionIdSchema, staffIdSchema } from '@contracts';
|
||||
import type {
|
||||
CreateStaffInput,
|
||||
OrgService,
|
||||
StaffWithPosition,
|
||||
} from '../../contract.ts';
|
||||
import {
|
||||
AccountTakenError,
|
||||
DisableBlockedError,
|
||||
PositionNotFoundError,
|
||||
StaffNotFoundError,
|
||||
} from '../../contract.ts';
|
||||
import type {
|
||||
GrantRepository,
|
||||
IdGenerator,
|
||||
InFlightWorkProbe,
|
||||
PositionRepository,
|
||||
StaffRepository,
|
||||
} from '../../port.ts';
|
||||
import { isAuthorized } from '../domain/authorization.ts';
|
||||
import { buildDisableCheck } from '../domain/disable-check.ts';
|
||||
import { POSITION_GRANT_SEED, POSITION_SEED, STAFF_SEED } from '../domain/seed-data.ts';
|
||||
|
||||
export type OrgServiceDeps = {
|
||||
readonly positions: PositionRepository;
|
||||
readonly staff: StaffRepository;
|
||||
readonly grants: GrantRepository;
|
||||
readonly inFlight: InFlightWorkProbe;
|
||||
readonly ids: IdGenerator;
|
||||
};
|
||||
|
||||
export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
async function positionMap(): Promise<Map<string, Position>> {
|
||||
const all = await deps.positions.list();
|
||||
return new Map(all.map((p) => [p.id, p]));
|
||||
}
|
||||
|
||||
async function withPosition(staff: Staff, positions?: Map<string, Position>): Promise<StaffWithPosition> {
|
||||
const map = positions ?? (await positionMap());
|
||||
const position = map.get(staff.positionId);
|
||||
if (!position) throw new PositionNotFoundError(staff.positionId);
|
||||
return { ...staff, position };
|
||||
}
|
||||
|
||||
async function grantsOfStaff(staffId: StaffId): Promise<Grant[]> {
|
||||
const staff = await deps.staff.findById(staffId);
|
||||
if (!staff) throw new StaffNotFoundError(staffId);
|
||||
return deps.grants.listByPosition(staff.positionId);
|
||||
}
|
||||
|
||||
return {
|
||||
async listStaff() {
|
||||
const [rows, positions] = await Promise.all([deps.staff.list(), deps.positions.list()]);
|
||||
const map = new Map(positions.map((p) => [p.id, p]));
|
||||
const staff = await Promise.all(rows.map((s) => withPosition(s, map)));
|
||||
return { staff, positions };
|
||||
},
|
||||
|
||||
async getStaff(staffId) {
|
||||
const staff = await deps.staff.findById(staffId);
|
||||
if (!staff) throw new StaffNotFoundError(staffId);
|
||||
return withPosition(staff);
|
||||
},
|
||||
|
||||
async findByAccount(account) {
|
||||
const staff = await deps.staff.findByAccount(account);
|
||||
if (!staff) return undefined;
|
||||
return withPosition(staff);
|
||||
},
|
||||
|
||||
async createStaff(input: CreateStaffInput) {
|
||||
const taken = await deps.staff.findByAccount(input.account);
|
||||
if (taken) throw new AccountTakenError(input.account);
|
||||
const positionId = positionIdSchema.parse(input.positionId);
|
||||
const position = await deps.positions.findById(positionId);
|
||||
if (!position) throw new PositionNotFoundError(input.positionId);
|
||||
return deps.staff.insert({
|
||||
id: staffIdSchema.parse(deps.ids.next()),
|
||||
account: input.account,
|
||||
name: input.name,
|
||||
positionId,
|
||||
state: 'active',
|
||||
});
|
||||
},
|
||||
|
||||
async checkDisable(staffId) {
|
||||
const staff = await deps.staff.findById(staffId);
|
||||
if (!staff) throw new StaffNotFoundError(staffId);
|
||||
const [openTasks, openCards, openApprovals] = await Promise.all([
|
||||
deps.inFlight.openTasksOf(staffId),
|
||||
deps.inFlight.openCardsOf(staffId),
|
||||
deps.inFlight.openApprovalsOf(staffId),
|
||||
]);
|
||||
return buildDisableCheck(staffId, { openTasks, openCards, openApprovals });
|
||||
},
|
||||
|
||||
async disableStaff(staffId) {
|
||||
const check = await this.checkDisable(staffId);
|
||||
if (check.blocked) throw new DisableBlockedError(check);
|
||||
await deps.staff.setState(staffId, 'disabled');
|
||||
},
|
||||
|
||||
async listGrants(positionId) {
|
||||
return deps.grants.listByPosition(positionIdSchema.parse(positionId));
|
||||
},
|
||||
|
||||
async setGrants(positionId, grants) {
|
||||
return deps.grants.replaceForPosition(positionIdSchema.parse(positionId), grants);
|
||||
},
|
||||
|
||||
async authorize(staffId: StaffId, object: GrantObject, action: GrantAction) {
|
||||
const staff = await deps.staff.findById(staffId);
|
||||
if (!staff || staff.state !== 'active') return false;
|
||||
const grants = await grantsOfStaff(staffId);
|
||||
return isAuthorized(grants, object, action);
|
||||
},
|
||||
|
||||
async loadSession(account): Promise<Session | undefined> {
|
||||
const staff = await deps.staff.findByAccount(account);
|
||||
if (!staff || staff.state !== 'active') return undefined;
|
||||
const position = await deps.positions.findById(staff.positionId);
|
||||
if (!position) throw new PositionNotFoundError(staff.positionId);
|
||||
const grants = await deps.grants.listByPosition(staff.positionId);
|
||||
return { staff, position, grants };
|
||||
},
|
||||
|
||||
async staffOfPosition(positionCode) {
|
||||
const position = await deps.positions.findByCode(positionCode);
|
||||
if (!position) throw new PositionNotFoundError(positionCode);
|
||||
const rows = await deps.staff.listByPosition(position.id);
|
||||
return rows.filter((s) => s.state === 'active').map((s) => ({ ...s, position }));
|
||||
},
|
||||
|
||||
async ensureSeeded() {
|
||||
const byCode = new Map<string, PositionId>();
|
||||
for (const seed of POSITION_SEED) {
|
||||
const existing = await deps.positions.findByCode(seed.code);
|
||||
const position = existing
|
||||
? existing
|
||||
: await deps.positions.upsert({
|
||||
id: positionIdSchema.parse(deps.ids.next()),
|
||||
code: seed.code,
|
||||
name: seed.name,
|
||||
note: seed.note,
|
||||
});
|
||||
byCode.set(seed.code, position.id);
|
||||
}
|
||||
|
||||
for (const seed of STAFF_SEED) {
|
||||
const existing = await deps.staff.findByAccount(seed.account);
|
||||
if (existing) continue;
|
||||
const positionId = byCode.get(seed.position);
|
||||
if (!positionId) throw new PositionNotFoundError(seed.position);
|
||||
await deps.staff.insert({
|
||||
id: staffIdSchema.parse(deps.ids.next()),
|
||||
account: seed.account,
|
||||
name: seed.name,
|
||||
positionId,
|
||||
state: 'active',
|
||||
});
|
||||
}
|
||||
|
||||
for (const [code, entries] of Object.entries(POSITION_GRANT_SEED)) {
|
||||
const positionId = byCode.get(code);
|
||||
if (!positionId) continue;
|
||||
const existing = await deps.grants.listByPosition(positionId);
|
||||
if (existing.length > 0) continue;
|
||||
await deps.grants.replaceForPosition(
|
||||
positionId,
|
||||
entries.map((e) => ({
|
||||
positionId,
|
||||
object: { kind: 'governance' as const, function: governanceFunctionSchema.parse(e.fn) },
|
||||
action: e.action,
|
||||
})),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@modules/org",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./index.ts" }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* module: org —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
* adapter 实现之,usecase 依赖之,app 装配之。
|
||||
*/
|
||||
import type { Grant, Position, PositionId, Staff, StaffId } from '@contracts';
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
|
||||
export type PositionRepository = {
|
||||
list(): Promise<Position[]>;
|
||||
findByCode(code: string): Promise<Position | undefined>;
|
||||
findById(id: PositionId): Promise<Position | undefined>;
|
||||
upsert(position: Omit<Position, 'id'> & { id?: PositionId }): Promise<Position>;
|
||||
};
|
||||
|
||||
export type StaffRepository = {
|
||||
list(): Promise<Staff[]>;
|
||||
findById(id: StaffId): Promise<Staff | undefined>;
|
||||
findByAccount(account: string): Promise<Staff | undefined>;
|
||||
listByPosition(positionId: PositionId): Promise<Staff[]>;
|
||||
insert(staff: Staff): Promise<Staff>;
|
||||
setState(id: StaffId, state: Staff['state']): Promise<void>;
|
||||
};
|
||||
|
||||
export type GrantRepository = {
|
||||
listByPosition(positionId: PositionId): Promise<Grant[]>;
|
||||
replaceForPosition(positionId: PositionId, grants: readonly Grant[]): Promise<Grant[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 在途工作探针 —— 停用前置检查要问的是 L6 `task` 与 L3 `human-tool`,
|
||||
* 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。
|
||||
*/
|
||||
export type InFlightWorkProbe = {
|
||||
openTasksOf(staffId: StaffId): Promise<Array<{ taskId: string; title: string }>>;
|
||||
openCardsOf(staffId: StaffId): Promise<Array<{ cardId: string; title: string }>>;
|
||||
openApprovalsOf(staffId: StaffId): Promise<Array<{ approvalId: string; title: string }>>;
|
||||
};
|
||||
|
||||
/** ID 生成 —— domain 零随机(§5.3) */
|
||||
export type IdGenerator = { next(): string };
|
||||
Reference in New Issue
Block a user