Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/hooks/adjustment.hook.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Hook, HookContext } from '@objectstack/spec/data';
import { actorId, fail, findById, hasPosition, isSystem, merged, nowIso, recordId, sys, toNumber, writeReview } from './util.js';
import { actorId, fail, findById, hasPosition, isSystemWrite, merged, nowIso, recordId, sys, toNumber, writeReview } from './util.js';
import { regenerateResults } from '../services/results-service.js';
import { provisionPlanSharing } from '../services/sharing-service.js';

Expand Down Expand Up @@ -43,7 +43,9 @@ export const AdjustmentHook: Hook = {
fail('保存调整申请失败:所选指标明细不属于该填报单。请重新选择。', 'KPI_ADJ_SHEET_MISMATCH');
}
const sheet = await findById(api, 'kpi_entry_sheet', line.sheet);
if (sheet?.status === 'archived' && !isSystem(ctx)) {
// 免检只给纯系统写入(无发起人):按钮的动作体带发起人以受信任身份写入,
// 只看 isSystem 会让「提交审批 / 批准并落地 / 否决」按钮绕过归档锁。
if (sheet?.status === 'archived' && !isSystemWrite(ctx)) {
fail('保存调整申请失败:填报单已归档,数据已锁定不可再改。', 'KPI_ADJ_ARCHIVED');
}

Expand All @@ -56,7 +58,7 @@ export const AdjustmentHook: Hook = {
return;
}

if (prev.status === 'approved' && !isSystem(ctx)) {
if (prev.status === 'approved' && !isSystemWrite(ctx)) {
const touched = Object.keys(input).filter((k) => k !== 'id' && input[k] !== prev[k]);
if (touched.length) fail('修改调整申请失败:已批准并落地的调整不能再修改。如需再次更正,请新建调整申请。', 'KPI_ADJ_LOCKED');
return;
Expand Down
8 changes: 5 additions & 3 deletions src/hooks/bonus.hook.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Hook, HookContext } from '@objectstack/spec/data';
import { actorId, fail, findById, hasPosition, isSystem, merged, nowIso, sys, toNumber } from './util.js';
import { actorId, fail, findById, hasPosition, isSystemWrite, merged, nowIso, sys, toNumber } from './util.js';
import { regenerateResults } from '../services/results-service.js';
import { provisionPlanSharing } from '../services/sharing-service.js';

Expand Down Expand Up @@ -66,7 +66,9 @@ export const BonusHook: Hook = {
const api = sys(ctx);

const sheet = await findById(api, 'kpi_entry_sheet', row.sheet);
if (sheet?.status === 'archived' && !isSystem(ctx)) {
// 免检只给纯系统写入(无发起人):按钮的动作体带发起人以受信任身份写入,
// 只看 isSystem 会让「批准 / 否决」按钮绕过归档锁。
if (sheet?.status === 'archived' && !isSystemWrite(ctx)) {
fail('修改加减分失败:填报单已归档,数据已锁定不可再改。', 'KPI_BONUS_ARCHIVED');
}
const points = toNumber(row.points) ?? 0;
Expand All @@ -79,7 +81,7 @@ export const BonusHook: Hook = {
input.approved_by = actorId(ctx);
input.approved_at = nowIso();
}
} else if (ctx.event === 'beforeUpdate' && prev.status === 'approved' && !isSystem(ctx)) {
} else if (ctx.event === 'beforeUpdate' && prev.status === 'approved' && !isSystemWrite(ctx)) {
const touched = Object.keys(input).filter((k) => !['id', 'signed_points'].includes(k) && input[k] !== prev[k]);
if (touched.length) fail('修改加减分失败:已批准的加减分不能再修改。如需更正,请否决后重新申请。', 'KPI_BONUS_LOCKED');
}
Expand Down
10 changes: 7 additions & 3 deletions src/hooks/check-task.hook.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Hook, HookContext } from '@objectstack/spec/data';
import { actorId, fail, findById, hasPosition, isSystem, merged, nowIso, sys, writeReview } from './util.js';
import { actorId, fail, findById, hasPosition, isSystemWrite, merged, nowIso, sys, sysNoActor, writeReview } from './util.js';

/** 核对任务:确认 / 争议时盖章、留痕;全部确认后自动推进填报单(蓝图 B-M4-04)。 */
export const CheckTaskDecideHook: Hook = {
Expand All @@ -12,7 +12,9 @@ export const CheckTaskDecideHook: Hook = {
const input = ctx.input as Record<string, any>;
const prev = (ctx.previous ?? {}) as Record<string, any>;
if (!('status' in input) || input.status === prev.status) return;
if (isSystem(ctx)) return;
// 免检只给纯系统写入(无发起人,例如驳回时的批量重置)。「确认无误 / 提出争议」两个
// 按钮的动作体带着发起人以受信任身份写入,只看 isSystem 会让岗位闸对按钮整体失效。
if (isSystemWrite(ctx)) return;
if (input.status === 'pending') {
fail('核对失败:已确认的核对不能撤回。如需重新核对,请由审核人驳回填报单。', 'KPI_CHECK_REVERT');
}
Expand Down Expand Up @@ -56,7 +58,9 @@ export const CheckTaskAfterDecideHook: Hook = {
if (pending === 0 && disputed === 0) {
const sheet = await findById(api, 'kpi_entry_sheet', sheetId);
if (sheet?.status === 'branch_checking') {
await api.object('kpi_entry_sheet').updateById(sheetId, { pending_action: 'approve', action_reason: '全部分公司已确认,系统自动推进' });
// 自动推进是系统动作:节点审核岗位按方案可改(《设计方案》10.1 第 9 条),
// 不能指望「最后一个确认的人恰好持有该节点岗位」,必须以无发起人的系统上下文写入。
await sysNoActor(ctx).object('kpi_entry_sheet').updateById(sheetId, { pending_action: 'approve', action_reason: '全部分公司已确认,系统自动推进' });
}
}
},
Expand Down
52 changes: 44 additions & 8 deletions src/hooks/sheet.hook.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Hook, HookContext } from '@objectstack/spec/data';
import { actorId, fail, findById, hasPosition, isSystem, merged, nowIso, recordId, sys, writeReview } from './util.js';
import { actorId, fail, findById, hasPosition, isSystem, isSystemWrite, merged, nowIso, recordId, sys, sysNoActor, writeReview } from './util.js';
import { requiredPositionFor, STATUS_LABEL, transition, type PlanStepDef, type SheetAction, type SheetStatus } from '../lib/workflow.js';
import { regenerateResults } from '../services/results-service.js';
import { provisionPlanSharing } from '../services/sharing-service.js';
Expand All @@ -15,7 +15,27 @@ export async function loadPlanSteps(api: ReturnType<typeof sys>, planId: string)
}));
}

/** 填报单由方案发布生成;非系统上下文不能手工新建。 */
/** 流程暂存字段:按钮写进来、after 阶段清空,不承载业务数据。 */
const SCRATCH_FIELDS = new Set(['pending_action', 'action_reason']);

/**
* 平台审计戳字段 —— 不是任何人「改」出来的,是平台内建 hook 盖上去的。
*
* 平台的 `sys_stamp_audit_update`(object `'*'`,priority 10)在本 hook(priority 100)
* **之前**跑,把 `updated_at` / `updated_by` 直接写进同一个 `ctx.input`。所以到本 hook
* 手里时,一次「只清空 pending_action」的写入,`input` 里已经多出两个字段。归档锁若照
* `input` 的字面内容判「有没有改动」,就会把平台自己盖的戳算成用户改数据 —— 归档流程
* 在 after 阶段的清场写入上被自己拦下,快照与审核记录都不会执行。
*/
const PLATFORM_STAMP_FIELDS = new Set(['created_at', 'created_by', 'updated_at', 'updated_by']);

/**
* 填报单由方案发布生成;非系统上下文不能手工新建。
*
* 这里的免检仍按 `isSystem` 而不是「无发起人」:发布是**用户点**「发布方案」触发的,
* 生成填报单由方案 hook 以带发起人的系统上下文完成,收紧会直接打断发布。填报单
* 也没有任何 insert 型按钮,动作体到不了这条分支;REST 手工新建走非系统上下文,照拦。
*/
export const SheetInsertGuardHook: Hook = {
name: 'kpi_sheet_insert_guard',
label: '填报单新建保护',
Expand Down Expand Up @@ -53,11 +73,18 @@ export const SheetTransitionHook: Hook = {
const action = input.pending_action as SheetAction | null | undefined;

if (!action) {
if ('status' in input && input.status !== prev.status && !isSystem(ctx)) {
// 免检只给纯系统写入(无发起人)。按钮的动作体带着发起人以「受信任」身份写入,
// 只看 isSystem 会把它当系统写入放行(objectstack#2849),这两条锁就等于没上。
if ('status' in input && input.status !== prev.status && !isSystemWrite(ctx)) {
fail('修改填报单状态失败:状态由流程推进,不能直接修改。请使用提交、审核通过、驳回或归档按钮。', 'KPI_SHEET_DIRECT_STATUS');
}
if (prev.status === 'archived' && !isSystem(ctx)) {
const touched = Object.keys(input).filter((k) => k !== 'id' && input[k] !== prev[k]);
if (prev.status === 'archived' && !isSystemWrite(ctx)) {
// 「改动」只算业务字段:流程暂存字段(after 阶段清场写的就是这两个)与平台审计戳
// (由更早的平台 hook 盖进同一个 input)都不是用户改数据。带真实动作的写入走不到
// 这个分支(上面 `action` 为真时已分流)。
const touched = Object.keys(input).filter(
(k) => !SCRATCH_FIELDS.has(k) && !PLATFORM_STAMP_FIELDS.has(k) && k !== 'id' && input[k] !== prev[k],
);
if (touched.length) fail('修改填报单失败:该填报单已归档,数据已锁定不可再改。', 'KPI_SHEET_ARCHIVED');
}
return;
Expand All @@ -69,6 +96,8 @@ export const SheetTransitionHook: Hook = {
const result = transition(steps, fromStatus, action);
if (!result.ok) fail(`操作失败:${result.message}`, 'KPI_SHEET_TRANSITION');

// 岗位闸:按**发起人**校验,不看写入是否带系统标记 —— 按钮路径与 REST 路径同一口径
// (hasPosition 的免检只留给无发起人的纯系统写入)。
const required = requiredPositionFor(steps, fromStatus, action);
if (!(await hasPosition(ctx, required))) {
fail(`操作失败:当前节点「${result.atStepDef?.label ?? STATUS_LABEL[fromStatus]}」需要由对应岗位处理,你没有该岗位。如需处理,请联系管理员分配岗位。`, 'KPI_SHEET_POSITION');
Expand Down Expand Up @@ -146,8 +175,11 @@ export const SheetAfterTransitionHook: Hook = {
if (!id) return;
const api = sys(ctx);
const actor = actorId(ctx);
// 先清空动作,再做副作用:副作用失败也不会让动作残留而被反复触发
await api.object('kpi_entry_sheet').updateById(id, { pending_action: null, action_reason: null });
// 先清空动作,再做副作用:副作用失败也不会让动作残留而被反复触发。
// 清场是系统动作,不是任何人点出来的 —— 用无发起人的系统上下文写,让它落在
// `isSystemWrite` 免检那一侧;否则归档后的这一次清场会被自己的归档锁拦下,
// 快照与审核记录都不会执行,填报单永久停在「已归档但没有快照」。
await sysNoActor(ctx).object('kpi_entry_sheet').updateById(id, { pending_action: null, action_reason: null });
const fromStatus = prev.status as SheetStatus;
const toStatus = now.status as SheetStatus;
const steps = await loadPlanSteps(api, String(now.plan ?? prev.plan));
Expand All @@ -171,8 +203,11 @@ export const SheetAfterTransitionHook: Hook = {
await api.object('kpi_check_task').insert({ name: `${sheetName} · ${bu?.name ?? b.subject} 核对`, sheet: id, plan: now.plan ?? prev.plan, branch: b.subject, status: 'pending' });
}
if (action === 'reject') {
// 重置是系统动作,不是「谁把核对撤回了」:用无发起人的系统上下文写,
// 否则会被核对 hook 的「已确认不能撤回」按发起人拦下。
const sysApi = sysNoActor(ctx);
for (const t of existing) {
if (t.status !== 'pending') await api.object('kpi_check_task').updateById(String(t.id), { status: 'pending', comment: null, decided_by: null, decided_at: null });
if (t.status !== 'pending') await sysApi.object('kpi_check_task').updateById(String(t.id), { status: 'pending', comment: null, decided_by: null, decided_at: null });
}
}
}
Expand Down Expand Up @@ -209,6 +244,7 @@ export const SheetDeleteGuardHook: Hook = {
events: ['beforeDelete'],
priority: 100,
handler: async (ctx: HookContext) => {
// 没有删除型按钮,动作体到不了这条分支;删除一律走非系统上下文,免检维持 isSystem。
if (isSystem(ctx)) return;
const prev = (ctx.previous ?? {}) as Record<string, any>;
if (prev.status && prev.status !== 'draft') {
Expand Down
41 changes: 39 additions & 2 deletions src/hooks/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,25 @@ export function sys(ctx: HookContext): Api {
return api.sudo ? api.sudo() : api;
}

/**
* 无发起人的系统上下文:在 `sudo()` 的基础上摘掉发起用户,租户与事务信息原样保留。
*
* 给 hook **内部**自发的写入用(核对全部完成后自动推进填报单、驳回时重置核对任务)。
* 这类写入不是任何人「点」出来的,按发起人校验岗位既没有对象也没有意义 —— 摘掉发起人
* 让它落在 {@link isSystemWrite} 这一侧,规则明确免检,而不是靠「恰好这个人有那个岗位」
* 蒙混过关(节点审核岗位是按方案配置的,见《设计方案》10.1 第 9 条)。
*/
export function sysNoActor(ctx: HookContext): Api {
const api = ctx.api as unknown as (Api & { sudo?: () => Api }) | undefined;
if (!api) fail('系统内部错误:数据访问上下文不可用,请稍后重试或联系管理员。', 'KPI_NO_API');
if (!api.sudo) return api;
const elevated = api.sudo() as Api & { executionContext?: Record<string, unknown> };
if (elevated && typeof elevated === 'object' && elevated.executionContext) {
elevated.executionContext = { ...elevated.executionContext, isSystem: true, userId: undefined };
}
return elevated;
}

/** 以当前用户上下文访问(受数据范围约束)。 */
export function user(ctx: HookContext): Api {
const api = ctx.api as unknown as Api | undefined;
Expand All @@ -51,6 +70,20 @@ export function actorId(ctx: HookContext): string | null {
return (ctx.user?.id as string | undefined) ?? (ctx.session?.userId as string | undefined) ?? null;
}

/**
* 是否是**纯系统写入** —— 带系统标记**并且**没有发起用户。
*
* 为什么不能只看 `isSystem`:平台执行动作体(按钮)时用的上下文是
* `{ ...调用者上下文, isSystem: true }`,发起人的 `userId` 原样保留。也就是说
* 「用户点了按钮」和「系统自己写」在 `isSystem` 这一位上完全一样,只用它做闸,
* 任何岗位的人点按钮都会被当成系统写入放行(objectstack-ai/objectstack#2849)。
* 真正的系统写入 —— 种子、脚本、hook 内部自动推进 —— 是**没有发起人**的,
* 这一位才把两者分得开。
*/
export function isSystemWrite(ctx: HookContext): boolean {
return isSystem(ctx) && actorId(ctx) === null;
}

/** 当前记录的合并视图(更新时 = 旧记录 + 本次改动)。 */
export function merged<T = Record<string, any>>(ctx: HookContext): T {
return { ...((ctx.previous ?? {}) as Record<string, unknown>), ...((ctx.input ?? {}) as Record<string, unknown>) } as T;
Expand All @@ -63,10 +96,14 @@ export function recordId(ctx: HookContext): string | null {

/**
* 是否持有岗位(业务规则,经 ctx.api 通道查询 sys_user_position;平台明示
* session.positions 只作描述、不作授权输入)。系统上下文放行;`kpi_admin` 通行。
* session.positions 只作描述、不作授权输入)。`kpi_admin` 通行。
*
* 免检只留给**纯系统写入**({@link isSystemWrite}:有系统标记且没有发起人)。
* 带发起人的写入一律按发起人校验 —— 无论它是从 REST 直接进来的,还是经按钮的
* 动作体以「受信任」身份进来的,两条路径同一口径。
*/
export async function hasPosition(ctx: HookContext, position: string | null): Promise<boolean> {
if (isSystem(ctx) || !position) return true;
if (!position || isSystemWrite(ctx)) return true;
const uid = actorId(ctx);
if (!uid) return false;
const api = sys(ctx);
Expand Down
Loading
Loading