app 装配 mail:channel 作首个订阅方、出站经 mail.send、mail.send 工具真发信(dryRun 只校验)、轮询工作者;上传件存取抽出共用

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
This commit is contained in:
xcs
2026-08-26 02:05:50 +08:00
co-authored by Claude Fable 5
parent 58358210e2
commit 0c6e8e2c1b
10 changed files with 449 additions and 48 deletions
@@ -0,0 +1,47 @@
/** 轮询工作者:每 tick 调 pollDue;上一 tick 没跑完就跳过这一拍;出错的账号写 stderr;stop 后不再跑 */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MailPollOutcome } from '@contracts';
import { pollMailOnce, startMailPoller } from '../workers/mail-poller.ts';
function fakeMail(outcomes: MailPollOutcome[], holdMs = 0) {
const calls: Date[] = [];
return {
calls,
mail: {
pollDue: async (now: Date) => {
calls.push(now);
if (holdMs > 0) await new Promise((r) => setTimeout(r, holdMs));
return outcomes;
},
},
};
}
describe('pollMailOnce', () => {
it('回用例的结果;出错的账号写一行 stderr', async () => {
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const { mail } = fakeMail([{ accountId: 'a', pulled: 1, stored: 1, dispatched: 1, more: false }, { accountId: 'b', pulled: 0, stored: 0, dispatched: 0, more: false, error: '连不上' }]);
const out = await pollMailOnce({ container: { mail: mail as never } });
expect(out).toHaveLength(2);
expect(write).toHaveBeenCalledWith(expect.stringContaining('[mail-poller] b:连不上'));
write.mockRestore();
});
});
describe('startMailPoller', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('按 tick 调;在跑时跳过;stop 后停', async () => {
const { mail, calls } = fakeMail([], 1000);
const poller = startMailPoller({ container: { mail: mail as never } }, 100);
await vi.advanceTimersByTimeAsync(350);
expect(calls).toHaveLength(1); // 第一拍 1000ms 没跑完,后面两拍跳过
await vi.advanceTimersByTimeAsync(1000);
expect(calls.length).toBeGreaterThanOrEqual(2);
poller.stop();
const before = calls.length;
await vi.advanceTimersByTimeAsync(2000);
expect(calls).toHaveLength(before);
});
});
@@ -0,0 +1,111 @@
/** mail ↔ channel / toolbox 的接线:三段纯映射各自成立;dryRun 不发;失败翻成工具错误 */
import { describe, expect, it } from 'vitest';
import type { MailInboundDetail, MailSendInput, MailSendResult } from '@contracts';
import type { InboundMail } from '@contracts';
import { channelSubscriber, channelTransport, mailSendTool, toInboundMail } from '../mail-wiring.ts';
const detail: MailInboundDetail = {
id: 'in-1',
accountId: 'acc-1',
messageId: '<m1@acme.com>',
from: { address: 'buyer@acme.com', name: '张三' },
to: [{ address: 'sales@we.com', name: '' }],
cc: [{ address: 'ops@we.com', name: 'Ops' }],
subject: '询价 RFQ-1',
sentAt: '2026-08-25T09:00:00.000Z',
receivedAt: '2026-08-25T09:30:00.000Z',
textPreview: '你好',
hasHtml: true,
attachments: [
{ index: 0, name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1', note: '' },
{ index: 1, name: 'big.zip', contentType: 'application/zip', size: 9_000_000, ref: null, note: '超限' },
],
deliveries: [],
inReplyTo: null,
references: ['<root@we.com>', '<q1@we.com>'],
text: null,
html: '<p>你好</p>',
size: 100,
rawAvailable: true,
};
function fakeSend(result: Partial<MailSendResult> = {}): { send: (input: MailSendInput) => Promise<MailSendResult>; calls: MailSendInput[] } {
const calls: MailSendInput[] = [];
return {
calls,
send: async (input) => {
calls.push(input);
return { outboundId: 'out-1', accountId: 'acc-1', messageId: '<out-1@we.com>', state: 'sent', ...result };
},
};
}
describe('toInboundMail', () => {
it('线程取 References 末项(没 In-Reply-To);只带存下来的附件;纯 html 剥成正文', () => {
const mail: InboundMail = toInboundMail(detail);
expect(mail.threadId).toBe('<q1@we.com>');
expect(mail.from).toBe('buyer@acme.com');
expect(mail.to).toEqual(['sales@we.com']);
expect(mail.cc).toEqual(['ops@we.com']);
expect(mail.body).toBe('你好');
expect(mail.attachments).toEqual([{ name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1' }]);
expect(mail.receivedAt).toBe('2026-08-25T09:30:00.000Z');
});
});
describe('channelSubscriber', () => {
it('账号没绑通道 → skipped;绑了就 ingestMail(通道 id + 归一后的信)', async () => {
const calls: Array<[string, InboundMail]> = [];
const handler = channelSubscriber({
ingestMail: async (id, mail) => {
calls.push([id, mail]);
return undefined;
},
});
expect(await handler(detail, { accountId: 'acc-1', channelId: undefined })).toEqual({ skipped: '账号未绑定通道' });
expect(await handler(detail, { accountId: 'acc-1', channelId: 'ch-1' })).toBeUndefined();
expect(calls[0]?.[0]).toBe('ch-1');
expect(calls[0]?.[1].messageId).toBe('<m1@acme.com>');
});
});
describe('channelTransport', () => {
it('按 from 找账号、threadId 进 inReplyTo / references、附件 ref 变 uploadRef;回 messageId 与线程', async () => {
const mail = fakeSend();
const sent = await channelTransport(mail).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: '报价', body: '正文', threadId: '<m1@acme.com>', attachments: [{ name: 'q.pdf', ref: 'up-9' }] });
expect(sent).toEqual({ messageId: '<out-1@we.com>', threadId: '<m1@acme.com>' });
expect(mail.calls[0]).toMatchObject({ from: 'sales@we.com', inReplyTo: '<m1@acme.com>', references: ['<m1@acme.com>'], text: '正文', attachments: [{ name: 'q.pdf', uploadRef: 'up-9' }], requestedBy: { kind: 'channel' } });
const fresh = await channelTransport(fakeSend()).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: 's', body: 'b', attachments: [] });
expect(fresh.threadId).toBe('<out-1@we.com>');
});
it('厂商失败抛出,让 channel.sendMail 的调用方看到', async () => {
await expect(channelTransport(fakeSend({ state: 'failed', error: '535 auth failed' })).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: 's', body: 'b', attachments: [] })).rejects.toThrow('535 auth failed');
});
});
describe('mailSendTool', () => {
const base = { connection: { account: 'default' }, operation: undefined, idempotencyKey: 'k-1' };
it('数组 / 逗号串都认作收件人;body → text;幂等键与连接配置的账号带过去;出参保留 queued', async () => {
const mail = fakeSend();
const result = await mailSendTool(mail)({ ...base, connection: { account: 'acc-2' }, input: { to: 'a@x.com; b@x.com', subject: 's', body: '正文', inReplyTo: '<m1@a>', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }, { name: 'no-ref' }] } });
expect(result).toEqual({ ok: true, output: { queued: true, outboundId: 'out-1', messageId: '<out-1@we.com>', state: 'sent' } });
expect(mail.calls[0]).toMatchObject({ accountId: 'acc-2', to: ['a@x.com', 'b@x.com'], text: '正文', inReplyTo: '<m1@a>', references: ['<m1@a>'], idempotencyKey: 'k-1', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }], requestedBy: { kind: 'tool', tool: 'mail.send' } });
await mailSendTool(mail)({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } });
expect(mail.calls[1]?.accountId).toBeUndefined();
});
it('dryRun 只校验不发', async () => {
const mail = fakeSend();
expect(await mailSendTool(mail)({ ...base, input: { to: ['x@y.com'], subject: '询价', body: 'b', dryRun: true } })).toEqual({ ok: true, output: { queued: false, dryRun: true } });
expect((await mailSendTool(mail)({ ...base, input: { to: [], subject: '', dryRun: true } })).ok).toBe(false);
expect(mail.calls).toHaveLength(0);
});
it('厂商失败 → send-failed 可重试;没账号 → no-account 不重试;入参问题 → invalid', async () => {
const failed = await mailSendTool(fakeSend({ state: 'failed', error: '535' }))({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } });
expect(failed).toEqual({ ok: false, error: { code: 'send-failed', retryable: true, message: '535' } });
const { MailNoDefaultAccountError, MailSendInvalidError } = await import('@modules/mail');
const noAccount = await mailSendTool({ send: async () => { throw new MailNoDefaultAccountError(); } })({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } });
expect(noAccount.error?.code).toBe('no-account');
const invalid = await mailSendTool({ send: async () => { throw new MailSendInvalidError(['正文 text / html 至少一个']); } })({ ...base, input: { to: ['a@x.com'], subject: 's' } });
expect(invalid.error?.code).toBe('invalid');
});
});
+3
View File
@@ -23,6 +23,8 @@ export type AppConfig = {
readonly attachmentRoot: string; readonly attachmentRoot: string;
/** 派发器轮询间隔(毫秒) */ /** 派发器轮询间隔(毫秒) */
readonly dispatchIntervalMs: number; readonly dispatchIntervalMs: number;
/** 邮件轮询工作者的 tick(毫秒);每个账号自己的间隔另算 */
readonly mailPollTickMs: number;
/** 三样齐了才有;缺一样即 undefined —— main.ts 据此拒绝启动,集成测试可不配 */ /** 三样齐了才有;缺一样即 undefined —— main.ts 据此拒绝启动,集成测试可不配 */
readonly keycloak?: KeycloakConfig; readonly keycloak?: KeycloakConfig;
/** 测试账号(Keycloak 用户名):首次建档 / 播种时落 TEST 岗 + 独立工作组 */ /** 测试账号(Keycloak 用户名):首次建档 / 播种时落 TEST 岗 + 独立工作组 */
@@ -65,6 +67,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
databaseUrl: env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL, databaseUrl: env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL,
attachmentRoot: env['JCAI_ATTACHMENT_ROOT'] ?? join(process.cwd(), '.data', 'attachments'), attachmentRoot: env['JCAI_ATTACHMENT_ROOT'] ?? join(process.cwd(), '.data', 'attachments'),
dispatchIntervalMs: Number(env['JCAI_DISPATCH_INTERVAL_MS'] ?? 2000), dispatchIntervalMs: Number(env['JCAI_DISPATCH_INTERVAL_MS'] ?? 2000),
mailPollTickMs: Number(env['JCAI_MAIL_POLL_TICK_MS'] ?? 5000),
...(keycloak === undefined ? {} : { keycloak }), ...(keycloak === undefined ? {} : { keycloak }),
testAccounts: (env['JCAI_TEST_ACCOUNTS'] ?? '') testAccounts: (env['JCAI_TEST_ACCOUNTS'] ?? '')
.split(',') .split(',')
+46 -10
View File
@@ -74,12 +74,19 @@ import type { TraceService } from '@modules/trace';
import { wireEngines } from './wiring/engines.ts'; import { wireEngines } from './wiring/engines.ts';
import { wireDeskElements } from './wiring/desk-elements.ts'; import { wireDeskElements } from './wiring/desk-elements.ts';
import type { ChannelService } from '@modules/channel'; import type { ChannelService } from '@modules/channel';
import { createChannelRepository, createChannelService, createFileAttachmentArchive } from '@modules/channel';
import type { MailService } from '@modules/mail';
import { import {
createChannelRepository, createImapSmtpProvider,
createChannelService, createInboundStore,
createFileAttachmentArchive, createMailAccountStore,
createSimulatedMailTransport, createMailService,
} from '@modules/channel'; createOutboundStore,
createPostalMimeParser,
createWecomProvider,
} from '@modules/mail';
import { channelSubscriber, channelTransport, mailSendTool } from './mail-wiring.ts';
import { readUpload, storeUpload } from './upload-store.ts';
import type { AppConfig } from './config.ts'; import type { AppConfig } from './config.ts';
import { createIdGenerator, systemClock } from './runtime.ts'; import { createIdGenerator, systemClock } from './runtime.ts';
@@ -107,6 +114,8 @@ export type Container = {
readonly toolInvoker: ToolInvoker; readonly toolInvoker: ToolInvoker;
readonly message: MessageService; readonly message: MessageService;
readonly channel: ChannelService; readonly channel: ChannelService;
/** 邮件收发(ADR-013):账号 / 轮询 / 订阅 / 发信 */
readonly mail: MailService;
readonly humanTool: HumanToolService; readonly humanTool: HumanToolService;
readonly interaction: InteractionService; readonly interaction: InteractionService;
readonly registry: ActivityRegistryService; readonly registry: ActivityRegistryService;
@@ -172,9 +181,6 @@ const sfeelEvaluator = (expression: string, scope: Readonly<Record<string, unkno
} }
}; };
/** 第一方工具实现 —— 由 app 注册进 ToolRuntime;活动只见调用契约 */
export const firstPartyToolHandlers = new Map<string, ToolHandler>(FIRST_PARTY_TOOLS);
/** 集成测试的注入缝:验签与名册来源都是外界,测试给替身,不联网 */ /** 集成测试的注入缝:验签与名册来源都是外界,测试给替身,不联网 */
export type ContainerOverrides = { export type ContainerOverrides = {
readonly tokenVerifier?: TokenVerifier; readonly tokenVerifier?: TokenVerifier;
@@ -369,9 +375,36 @@ export async function createContainer(config: AppConfig, overrides: ContainerOve
ids, ids,
}); });
/*
* 邮件收发(ADR-013)—— L1,只要库 / 密封 / 时钟;先于工具运行时装配,`mail.send` 的处理器要闭包持它。
* 变更经 ChangeNotifier 接到广播:轮询器不走 /api/gov/* 中间件,新来信 / 拉取出错得自己说一声。
* 附件落 sys.upload —— 与上传路由同一段存取(8MB 上限、文本抽取、下载路由一并复用)。
*/
const mail = createMailService({
accounts: createMailAccountStore(db),
inbound: createInboundStore(db),
outbound: createOutboundStore(db),
attachments: {
put: async (file) => ({
ref: (await storeUpload(db, { name: file.name, contentType: file.contentType, bytes: file.bytes, uploadedBy: null, note: file.note })).ref,
}),
get: (ref) => readUpload(db, ref),
},
providers: { 'imap-smtp': createImapSmtpProvider(), wecom: createWecomProvider() },
mime: createPostalMimeParser(),
secrets,
clock: systemClock,
ids,
notify: { changed: (reason) => broadcaster.publish({ kind: 'system', id: 'mail', reason }) },
});
const toolRegistry = createToolRegistry(db); const toolRegistry = createToolRegistry(db);
/** 第一方工具实现 —— `mail.send` 真发信,处理器在这儿闭包持 mail(放行时经 ToolInvoker 执行) */
// 导入的自定义工具走通用 HTTP 实现:契约申报 endpoint,运行时只把那一个连接目标交出去 // 导入的自定义工具走通用 HTTP 实现:契约申报 endpoint,运行时只把那一个连接目标交出去
const toolRuntime = createToolRuntime(firstPartyToolHandlers, httpToolHandler); const toolRuntime = createToolRuntime(
new Map<string, ToolHandler>([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]),
httpToolHandler,
);
const schemas = createSchemaValidator(); const schemas = createSchemaValidator();
const toolbox = createToolboxService({ const toolbox = createToolboxService({
registry: toolRegistry, registry: toolRegistry,
@@ -399,11 +432,13 @@ export async function createContainer(config: AppConfig, overrides: ContainerOve
recordOutbound: (ref) => event.recordOutbound(ref), recordOutbound: (ref) => event.recordOutbound(ref),
}, },
ledger: { systemWrite: (input) => ledger.systemWrite(input) }, ledger: { systemWrite: (input) => ledger.systemWrite(input) },
mail: createSimulatedMailTransport(db, ids), mail: channelTransport(mail),
archive: createFileAttachmentArchive(config.attachmentRoot), archive: createFileAttachmentArchive(config.attachmentRoot),
clock: systemClock, clock: systemClock,
ids, ids,
}); });
// channel 是第一个订阅方:来信归一进事件流,活动经触发器 / 监听收到;账号没绑通道的信留在运营面
mail.subscribe('channel', channelSubscriber(channel));
/* ── L3/L4:交互、人类工具、活动库、放行门 ─────────────────────── */ /* ── L3/L4:交互、人类工具、活动库、放行门 ─────────────────────── */
@@ -775,6 +810,7 @@ export async function createContainer(config: AppConfig, overrides: ContainerOve
toolInvoker, toolInvoker,
message, message,
channel, channel,
mail,
humanTool, humanTool,
interaction, interaction,
registry, registry,
+150
View File
@@ -0,0 +1,150 @@
/**
* mail 与 channel / toolbox 的接线(app 层;设计稿 §8;ADR-013):
* ① channel 订阅方:入站 → channel.ingestMail(账号绑的通道)—— 活动经现有事件链收到信
* ② channel 的 MailTransport:出站 → mail.send(按 from 找账号,对不上不回落默认)
* ③ `mail.send` 工具处理器:活动侧出站,放行时经 ToolInvoker 执行;dryRun 只校验不发(注册试运行会真调处理器)
* 三段都是纯映射 + 一次调用,单测直接对函数。
*/
import type { InboundMail, MailSendInput } from '@contracts';
import type { MailTransport } from '@modules/channel';
import type { InboundHandler, MailInboundDetail, MailService } from '@modules/mail';
import {
bodyTextOf,
MailAccountDisabledError,
MailAccountNotFoundError,
MailNoDefaultAccountError,
MailSecretMissingError,
MailSendInvalidError,
} from '@modules/mail';
import type { ToolHandler } from '@modules/toolbox';
/** mail 的入站详情 → channel 的 InboundMail:只带存下来的附件;线程 = In-Reply-To ⊃ References 末项 */
export function toInboundMail(mail: MailInboundDetail): InboundMail {
const threadId = mail.inReplyTo ?? mail.references.at(-1);
return {
messageId: mail.messageId,
...(threadId === undefined ? {} : { threadId }),
from: mail.from.address,
to: mail.to.map((a) => a.address),
cc: mail.cc.map((a) => a.address),
subject: mail.subject,
body: bodyTextOf({ ...(mail.text === null ? {} : { text: mail.text }), ...(mail.html === null ? {} : { html: mail.html }) }),
receivedAt: mail.receivedAt,
attachments: mail.attachments.flatMap((a) =>
a.ref === null ? [] : [{ name: a.name, contentType: a.contentType, size: a.size, ref: a.ref }],
),
};
}
/** 只要 `ingestMail` 这一个口,出参不看(InboundOutcome 由 channel 自己留痕) */
export function channelSubscriber(channel: { ingestMail(channelId: string, mail: InboundMail): Promise<unknown> }): InboundHandler {
return async (mail, ctx) => {
if (ctx.channelId === undefined) return { skipped: '账号未绑定通道' };
await channel.ingestMail(ctx.channelId, toInboundMail(mail));
// 显式回 undefined —— 投递成功就是「没话说」;`noImplicitReturns` 不许一条路返值一条路掉出去
return undefined;
};
}
/** channel 的出站面:厂商失败抛出 —— channel.sendMail 的调用方(放行落地)要看到失败 */
export function channelTransport(mail: Pick<MailService, 'send'>): MailTransport {
return {
async send(input) {
const result = await mail.send({
from: input.from,
to: [...input.to],
cc: [...input.cc],
bcc: [],
subject: input.subject,
text: input.body,
...(input.threadId === undefined ? {} : { inReplyTo: input.threadId }),
references: input.threadId === undefined ? [] : [input.threadId],
attachments: input.attachments.map((a) => ({ name: a.name, contentType: 'application/octet-stream', uploadRef: a.ref })),
requestedBy: { kind: 'channel' },
});
if (result.state === 'failed') throw new Error(`发信失败:${result.error ?? '未知'}`);
return { messageId: result.messageId, threadId: input.threadId ?? result.messageId };
},
};
}
function recipientsOf(value: unknown): string[] {
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string').map((v) => v.trim()).filter((v) => v !== '');
if (typeof value === 'string') return value.split(/[,;]/).map((s) => s.trim()).filter((s) => s !== '');
return [];
}
function attachmentsOf(value: unknown): MailSendInput['attachments'] {
if (!Array.isArray(value)) return [];
return value.flatMap((v) => {
if (typeof v !== 'object' || v === null) return [];
const a = v as Record<string, unknown>;
const uploadRef = typeof a['uploadRef'] === 'string' && a['uploadRef'] !== '' ? a['uploadRef'] : undefined;
if (uploadRef === undefined) return [];
const name = typeof a['name'] === 'string' && a['name'] !== '' ? a['name'] : uploadRef;
return [{ name, contentType: 'application/octet-stream', uploadRef }];
});
}
export function mailSendTool(mail: Pick<MailService, 'send'>): ToolHandler {
return async ({ input, connection, idempotencyKey }) => {
const to = recipientsOf(input['to']);
const cc = recipientsOf(input['cc']);
const bcc = recipientsOf(input['bcc']);
const subject = typeof input['subject'] === 'string' ? input['subject'] : '';
const body = typeof input['body'] === 'string' && input['body'] !== '' ? input['body'] : undefined;
const html = typeof input['html'] === 'string' && input['html'] !== '' ? input['html'] : undefined;
const inReplyTo = typeof input['inReplyTo'] === 'string' && input['inReplyTo'] !== '' ? input['inReplyTo'] : undefined;
// 账号:入参指定 > 连接配置('default' = 默认账号)
const configured = connection['account'];
const accountId =
typeof input['accountId'] === 'string' && input['accountId'] !== ''
? input['accountId']
: configured !== undefined && configured !== '' && configured !== 'default'
? configured
: undefined;
if (input['dryRun'] === true) {
const problems = [
...(to.length === 0 ? ['收件人至少一个'] : []),
...(subject === '' ? ['主题不能为空'] : []),
...(body === undefined && html === undefined ? ['正文 body / html 至少一个'] : []),
];
return problems.length === 0
? { ok: true, output: { queued: false, dryRun: true } }
: { ok: false, error: { code: 'invalid', retryable: false, message: problems.join('; ') } };
}
try {
const result = await mail.send({
...(accountId === undefined ? {} : { accountId }),
to,
cc,
bcc,
subject,
...(body === undefined ? {} : { text: body }),
...(html === undefined ? {} : { html }),
...(inReplyTo === undefined ? {} : { inReplyTo }),
references: inReplyTo === undefined ? [] : [inReplyTo],
attachments: attachmentsOf(input['attachments']),
...(idempotencyKey === undefined ? {} : { idempotencyKey }),
requestedBy: { kind: 'tool', tool: 'mail.send' },
});
if (result.state === 'failed') {
return { ok: false, error: { code: 'send-failed', retryable: true, message: result.error ?? '发信失败' } };
}
return { ok: true, output: { queued: true, outboundId: result.outboundId, messageId: result.messageId, state: result.state } };
} catch (error) {
if (error instanceof MailSendInvalidError) return { ok: false, error: { code: 'invalid', retryable: false, message: error.message } };
if (
error instanceof MailNoDefaultAccountError ||
error instanceof MailAccountNotFoundError ||
error instanceof MailAccountDisabledError ||
error instanceof MailSecretMissingError
) {
return { ok: false, error: { code: 'no-account', retryable: false, message: error.message } };
}
throw error;
}
};
}
+5
View File
@@ -10,6 +10,7 @@ import { createServer } from './server.ts';
import { createConsumers } from './workers/consumers.ts'; import { createConsumers } from './workers/consumers.ts';
import { dispatchOnce, startDispatcher } from './workers/dispatcher.ts'; import { dispatchOnce, startDispatcher } from './workers/dispatcher.ts';
import { advanceOnce, startTaskAdvancer } from './workers/task-advancer.ts'; import { advanceOnce, startTaskAdvancer } from './workers/task-advancer.ts';
import { startMailPoller } from './workers/mail-poller.ts';
import { registerPulse } from './workers/pulse.ts'; import { registerPulse } from './workers/pulse.ts';
export async function boot(): Promise<void> { export async function boot(): Promise<void> {
@@ -38,6 +39,9 @@ export async function boot(): Promise<void> {
// 过程在后端:运行中的任务由推进器一拍一拍推,不依赖浏览器在线 // 过程在后端:运行中的任务由推进器一拍一拍推,不依赖浏览器在线
const advancer = startTaskAdvancer({ container }, config.dispatchIntervalMs); const advancer = startTaskAdvancer({ container }, config.dispatchIntervalMs);
// 邮件轮询:腾讯没有带内容的推送,信箱只能拉;跨进程租约让两台后端不重复拉同一个信箱
const mailPoller = startMailPoller({ container }, config.mailPollTickMs);
/* /*
* 人一答完就踢一脚 —— **顺序有意义**:先把事件投递下去(任务才醒),再推它一拍。 * 人一答完就踢一脚 —— **顺序有意义**:先把事件投递下去(任务才醒),再推它一拍。
* 反过来登记的话,推进那一轮跑在投递之前,任务还没醒,白跑一轮, * 反过来登记的话,推进那一轮跑在投递之前,任务还没醒,白跑一轮,
@@ -54,6 +58,7 @@ export async function boot(): Promise<void> {
const shutdown = async (): Promise<void> => { const shutdown = async (): Promise<void> => {
dispatcher.stop(); dispatcher.stop();
advancer.stop(); advancer.stop();
mailPoller.stop();
await container.close(); await container.close();
process.exit(0); process.exit(0);
}; };
+4 -28
View File
@@ -5,7 +5,7 @@
* 「治理动作本身留痕入事件流 —— 治理也被治理。」 * 「治理动作本身留痕入事件流 —— 治理也被治理。」
*/ */
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { extractText } from '../upload-extract.ts'; import { readUpload, storeUpload, UPLOAD_CAP } from '../upload-store.ts';
import type { Hono } from 'hono'; import type { Hono } from 'hono';
import type { ApiError } from '@contracts'; import type { ApiError } from '@contracts';
import { import {
@@ -721,9 +721,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
/* ── 上传件:附件要能真正进活动 ─────────────────────────────────── */ /* ── 上传件:附件要能真正进活动 ─────────────────────────────────── */
/** 上限 8MB —— 超了当场拒,不静默截断(截断过的文件比没有更害人) */
const UPLOAD_CAP = 8 * 1024 * 1024;
app.post(uploadFileRoute.path, async (c) => { app.post(uploadFileRoute.path, async (c) => {
const parsed = uploadFileRoute.body.safeParse(await c.req.json()); const parsed = uploadFileRoute.body.safeParse(await c.req.json());
if (!parsed.success) { if (!parsed.success) {
@@ -744,37 +741,16 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
return c.json(body, 400); return c.json(body, 400);
} }
// 读文本就在入口这一步做完:活动那一侧读得懂的只有文字,读不出就如实记 note // 读文本就在入口这一步做完:活动那一侧读得懂的只有文字,读不出就如实记 note
const got = extractText(name, contentType, bytes); return c.json(await storeUpload(container.db, { name, contentType, bytes, uploadedBy: staffOf(c)?.id ?? null }));
const me = staffOf(c);
const ref = crypto.randomUUID();
await container.db.execute(sql`
insert into sys.upload (id, name, content_type, size, bytes, text, note, uploaded_by)
values (${ref}, ${name}, ${contentType}, ${bytes.length}, ${Buffer.from(bytes)},
${got.text ?? null}, ${got.note}, ${me?.id ?? null})`);
return c.json({
ref,
name,
contentType,
size: bytes.length,
...(got.text === undefined ? {} : { text: got.text }),
note: got.note,
});
}); });
app.get(downloadFileRoute.path, async (c) => { app.get(downloadFileRoute.path, async (c) => {
const rows = await container.db.execute<{ name: string; content_type: string; bytes: Buffer }>( const hit = await readUpload(container.db, c.req.param('id'));
sql`select name, content_type, bytes from sys.upload where id = ${c.req.param('id')}`,
);
const hit = rowsOf<{ name: string; content_type: string; bytes: Buffer }>(rows)[0];
if (hit === undefined) { if (hit === undefined) {
const body: ApiError = { code: 'not-found', message: '这个文件不在了' }; const body: ApiError = { code: 'not-found', message: '这个文件不在了' };
return c.json(body, 404); return c.json(body, 404);
} }
return c.json({ return c.json({ name: hit.name, contentType: hit.contentType, dataBase64: Buffer.from(hit.bytes).toString('base64') });
name: hit.name,
contentType: hit.content_type,
dataBase64: Buffer.from(hit.bytes).toString('base64'),
});
}); });
/* ── 试跑日志:历次试跑的存档,按实例管理 ────────────────────────── */ /* ── 试跑日志:历次试跑的存档,按实例管理 ────────────────────────── */
-10
View File
@@ -286,15 +286,6 @@ const rankKeyPersons: ToolHandler = async ({ input }) => {
return { ok: true, output: { ranked, counts } }; return { ok: true, output: { ranked, counts } };
}; };
/** 出站发送 —— 写类:过程中登记为动作意向,放行后由系统执行落地(OS 7.3) */
const mailSend: ToolHandler = async ({ input, connection }) => {
// 运行时只拿得到申报过的连接目标;真发信由 channel 的出站面承担
return {
ok: true,
output: { queued: true, to: String(input['to'] ?? ''), via: connection['endpoint'] ?? 'simulated' },
};
};
/* ── 活动槽位的占位产出(编排层跑通期专用) ───────────────────────────── /* ── 活动槽位的占位产出(编排层跑通期专用) ─────────────────────────────
* *
* 六个活动都只有契约面、没有实现。而出口载荷只从**落地事实**推导, * 六个活动都只有契约面、没有实现。而出口载荷只从**落地事实**推导,
@@ -349,7 +340,6 @@ export const FIRST_PARTY_TOOLS: ReadonlyArray<readonly [string, ToolHandler]> =
['quote-pricing.floor-gate', floorGate], ['quote-pricing.floor-gate', floorGate],
['quote-archive.profile', profile], ['quote-archive.profile', profile],
['quote-archive.landing-rate', landingRate], ['quote-archive.landing-rate', landingRate],
['mail.send', mailSend],
['slot.placeholder', slotPlaceholder], ['slot.placeholder', slotPlaceholder],
['crm-relationship-coverage.recompute-score', recomputeScore], ['crm-relationship-coverage.recompute-score', recomputeScore],
['crm-relationship-coverage.rank-key-persons', rankKeyPersons], ['crm-relationship-coverage.rank-key-persons', rankKeyPersons],
+42
View File
@@ -0,0 +1,42 @@
/**
* sys.upload 的存取 —— 上传路由与 mail 的附件注入件同用这一段(设计稿 §8)。
* 表是 0024 手写的 SQL,不在 Drizzle 里;照旧走 sql``。
*/
import { sql } from 'drizzle-orm';
import type { Db } from '@shared/db.ts';
import { extractText } from './upload-extract.ts';
/** 上限 8MB —— 超了当场拒,不静默截断(截断过的文件比没有更害人) */
export const UPLOAD_CAP = 8 * 1024 * 1024;
function rowsOf<T>(result: unknown): T[] {
const bag = result as { rows?: T[] } | T[];
return Array.isArray(bag) ? bag : (bag.rows ?? []);
}
export type StoredUpload = { ref: string; name: string; contentType: string; size: number; text?: string; note: string };
/** 存字节 + 读出来的文本;`note` 给了就拼在抽取说明前面(邮件附件写「邮件附件:主题」) */
export async function storeUpload(
db: Db,
file: { name: string; contentType: string; bytes: Uint8Array; uploadedBy: string | null; note?: string | undefined },
): Promise<StoredUpload> {
const got = extractText(file.name, file.contentType, file.bytes);
const ref = crypto.randomUUID();
const note = file.note === undefined || file.note === '' ? got.note : `${file.note};${got.note}`;
await db.execute(sql`
insert into sys.upload (id, name, content_type, size, bytes, text, note, uploaded_by)
values (${ref}, ${file.name}, ${file.contentType}, ${file.bytes.length}, ${Buffer.from(file.bytes)},
${got.text ?? null}, ${note}, ${file.uploadedBy})`);
return { ref, name: file.name, contentType: file.contentType, size: file.bytes.length, ...(got.text === undefined ? {} : { text: got.text }), note };
}
export async function readUpload(db: Db, ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined> {
const rows = await db.execute<{ name: string; content_type: string; bytes: Buffer }>(
sql`select name, content_type, bytes from sys.upload where id = ${ref}`,
);
const hit = rowsOf<{ name: string; content_type: string; bytes: Buffer }>(rows)[0];
if (hit === undefined) return undefined;
// pg 的 Buffer 挂在共享内存池上,按元素拷(2026-08-23 的前科)
return { name: hit.name, contentType: hit.content_type, bytes: Uint8Array.from(hit.bytes) };
}
+41
View File
@@ -0,0 +1,41 @@
/**
* 邮件轮询工作者 —— 每 tick 挑到期账号各拉一轮(设计稿 §4);跨进程租约在 store 里,
* 两个后端连同一个库时一个信箱同一时刻只有一个在拉。
* 与 startDispatcher / startTaskAdvancer 同形;上一 tick 没跑完(账号多、信多)就跳过这一拍,不叠着跑。
*/
import type { MailPollOutcome } from '@modules/mail';
import type { Container } from '../container.ts';
export type MailPollerDeps = { readonly container: Pick<Container, 'mail'>; readonly now?: () => Date };
export async function pollMailOnce(deps: MailPollerDeps): Promise<MailPollOutcome[]> {
const outcomes = await deps.container.mail.pollDue((deps.now ?? (() => new Date()))());
for (const outcome of outcomes) {
if (outcome.error !== undefined) process.stderr.write(`[mail-poller] ${outcome.accountId}:${outcome.error}\n`);
}
return outcomes;
}
export function startMailPoller(deps: MailPollerDeps, tickMs: number): { stop(): void } {
let stopped = false;
let running = false;
const tick = async (): Promise<void> => {
if (stopped || running) return;
running = true;
try {
await pollMailOnce(deps);
} catch (error) {
process.stderr.write(`[mail-poller] ${error instanceof Error ? error.message : String(error)}\n`);
} finally {
running = false;
}
};
const timer = setInterval(() => void tick(), tickMs);
timer.unref();
return {
stop() {
stopped = true;
clearInterval(timer);
},
};
}