This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/modules/trigger/internal/adapter/trigger-store.ts
T
ChaiHaoandClaude Opus 5 f9b2c07731 三面拆分⑬:触发确认链路 + 运营中心「工作流」区 —— 设计只管设计,运营管细节
**触发确认链路**(用户口述五步的第 4/5 步):
  接收邮件 → 分类识别 → 命中 RFQ 事件 → ④(可选,运营勾)系统消息到指定人
  工作台(带邮件内容)→ ⑤ 工作台点「确认起单」,流程才起。
  · 命中不等于必起:勾了「需人工确认」,装配好的入参存成待确认单
    (sys.trigger_pending,migration 0019),ConfirmDesk 端口把带来件内容的
    系统消息送指定人(eventRef 挂票号,消息列表就地贴确认状态与那枚钮)。
  · 先认领后起单:consumed_at 空才认得到 —— 两人同时点、一人点两次,都只起一条流;
    起单失败退回认领,不留死票。确认人记进 source.by,留痕可查。
  · 登记触发不走确认(登记那一下本身就是人在确认);勾了没指定人 = 配置错误入死信。
  · 4 条单测 + 全链实测:投信 → triggersHit=1 且零直起 → AI 工作台收到确认消息
    → 确认起单 → 二次确认幂等回同一流号 → source.by = AI。

**运营中心重排**(区名即运行时:工作流是流程包的运行时,任务是活动的运行时):
  · 新「工作流」区:在途看板(从任务区迁入)+ 运行配置 —— 每包的触发器
    起单开关 / 确认打勾 / 确认人,和节点负责人(sys.node_owner)。
  · 负责人三层:显式指派 > 运营配置 > 轮值 —— 合并点在 process.start,
    复用既有派发链一行不改。
  · 任务区瘦身成活动运行时(任务/审批/需处置);知识库区改名「数据」
    (与设计中心的知识库解开同名);系统区只剩通道实例。
  · 设计中心触发器详情页摘掉启停按钮 —— 设计只管设计,启停/确认全在运营。
  · 旧深链有改嫁:tasks/board → runs/board,tasks/trg → runs/conf。

reset-runs 补 sys.trigger_pending(待确认单是运行痕 —— 和上回漏 trace_span 同类洞)。
邮件分类 Agent(第 ② 步)现由抽键正则 + S-FEEL 代位,升级路径记进 debt.md。
产品设计.md 运营章、trigger/process 两份 CONTRACT.md 同步。

五闸绿;契约快照随本提交入库。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-29 13:09:16 +08:00

139 lines
5.0 KiB
TypeScript

/**
* trigger 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
*/
import { eq, inArray, isNull, and } from 'drizzle-orm';
import type { TriggerConfig } from '@contracts';
import { triggerConfigSchema } from '@contracts';
import type { Db } from '@shared/db.ts';
import type { PendingFire } from '../../contract.ts';
import type { TriggerStore } from '../../port.ts';
import { triggerClaimTable, triggerPendingTable, triggerTable } from './trigger-schema.ts';
type Row = typeof triggerTable.$inferSelect;
function toConfig(row: Row): TriggerConfig {
return triggerConfigSchema.parse({
id: row.id,
name: row.name,
enabled: row.enabled,
processPackageId: row.processPackageId,
mapping: row.mapping,
spec: row.spec,
confirm: { required: row.confirmRequired, ...(row.confirmStaffId === null ? {} : { staffId: row.confirmStaffId }) },
});
}
type PendingRow = typeof triggerPendingTable.$inferSelect;
function toPending(row: PendingRow): PendingFire {
return {
id: row.id,
triggerId: row.triggerId,
packageId: row.packageId,
staffId: row.staffId,
subject: row.subject,
data: row.data as Record<string, unknown>,
source: row.source as PendingFire['source'],
preview: row.preview as Record<string, string>,
createdAt: row.createdAt.toISOString(),
...(row.consumedAt === null ? {} : { consumedAt: row.consumedAt.toISOString() }),
...(row.workflowId === null ? {} : { workflowId: row.workflowId }),
};
}
export function createTriggerStore(db: Db): TriggerStore {
return {
async list() {
const rows = await db.select().from(triggerTable).orderBy(triggerTable.name);
return rows.map(toConfig);
},
async get(id) {
const rows = await db.select().from(triggerTable).where(eq(triggerTable.id, id)).limit(1);
return rows[0] === undefined ? undefined : toConfig(rows[0]);
},
async upsert(config) {
// 设计面的 upsert **不含 confirm 两列** —— 运营的勾只归 setConfirm 动
const values = {
id: config.id,
name: config.name,
enabled: config.enabled,
processPackageId: config.processPackageId,
mapping: config.mapping,
spec: config.spec,
};
const [row] = await db
.insert(triggerTable)
.values(values)
.onConflictDoUpdate({ target: triggerTable.id, set: values })
.returning();
if (row === undefined) throw new Error('触发器写入未返回行');
return toConfig(row);
},
async setEnabled(id, enabled) {
await db.update(triggerTable).set({ enabled }).where(eq(triggerTable.id, id));
},
async remove(id) {
await db.delete(triggerTable).where(eq(triggerTable.id, id));
},
async claim(key) {
const inserted = await db
.insert(triggerClaimTable)
.values({ key })
.onConflictDoNothing()
.returning({ key: triggerClaimTable.key });
return inserted.length > 0;
},
async lastFiredAt(id) {
const rows = await db.select({ at: triggerTable.lastFiredAt }).from(triggerTable).where(eq(triggerTable.id, id)).limit(1);
return rows[0]?.at ?? undefined;
},
async markFired(id, at) {
await db.update(triggerTable).set({ lastFiredAt: at }).where(eq(triggerTable.id, id));
},
async setConfirm(id, confirm) {
await db
.update(triggerTable)
.set({ confirmRequired: confirm.required, confirmStaffId: confirm.staffId ?? null })
.where(eq(triggerTable.id, id));
},
async createPending(pending) {
await db.insert(triggerPendingTable).values({
id: pending.id,
triggerId: pending.triggerId,
packageId: pending.packageId,
staffId: pending.staffId,
subject: pending.subject,
data: pending.data,
source: pending.source,
preview: pending.preview,
});
},
async getPending(id) {
const rows = await db.select().from(triggerPendingTable).where(eq(triggerPendingTable.id, id)).limit(1);
return rows[0] === undefined ? undefined : toPending(rows[0]);
},
async pendingByIds(ids) {
if (ids.length === 0) return [];
const rows = await db.select().from(triggerPendingTable).where(inArray(triggerPendingTable.id, [...ids]));
return rows.map(toPending);
},
async claimPending(id, at) {
// 只认 consumed_at 还空着的 —— 两人同时点确认,数据库替我们选出唯一赢家
const rows = await db
.update(triggerPendingTable)
.set({ consumedAt: at })
.where(and(eq(triggerPendingTable.id, id), isNull(triggerPendingTable.consumedAt)))
.returning({ id: triggerPendingTable.id });
return rows.length > 0;
},
async setPendingWorkflow(id, workflowId) {
await db.update(triggerPendingTable).set({ workflowId }).where(eq(triggerPendingTable.id, id));
},
async releasePending(id) {
await db.update(triggerPendingTable).set({ consumedAt: null }).where(eq(triggerPendingTable.id, id));
},
};
}