diff --git a/docs/architecture.md b/docs/architecture.md index 7e783e6..1ae5093 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,6 +44,8 @@ Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId 历史列表不与待办混合,也不签发 `selectionId`。未指定时间时默认查询最近 30 天;`startDate/endDate` 接受按 `Asia/Shanghai` 解释的 `YYYY-MM-DD` 或带时区的 ISO 8601 时间,结束日期包含全天,单次跨度最多 120 天。钉钉 `handled` 从本人已完成工作记录取得,`submitted` 在该时间窗口内按当前用户可见模板查询本人发起实例;薪福通 `handled` 从本人的已处理任务取得,`submitted` 使用服务器派生的本人员工号查询单据,再由网关统一按时间窗口过滤。读取历史详情时再次验证当前用户确实处理过该任务或确实是发起人;审批正文、费用明细和附件仍只实时读取、不落库。 +薪福通任务详情查询先对任务 ID 去重,再按每批最多 300 个顺序请求并按任务 ID 合并;任何一批失败都使本次查询失败,不将部分结果视为完整核验,也不触发审批写入重试。单笔待办及已办详情的归属核验采用每页 100 条、找到即停止的扫描方式,仍最多覆盖 100,000 条任务;翻页依据原始列表条数,不能依据筛选后的结果条数。列表字段可能不完整,因此仍先读取任务详情再识别目标单据,只有匹配目标才查询单据摘要和正文;未找到或超过扫描上限均不能绕过归属核验。 + 发起费用审批时,`approval_prepare_submission` 在网关内部完成跨来源发票查重。它从待提交表单和本人近 30 天 `submitted` 历史详情中有界提取发票代码/号码、数电票唯一编号、票据号码和附件哈希;金额、日期、商户三项组合仅作为弱匹配。首次索引只读取近 30 天尚未索引的本人历史详情,后续准备仍实时刷新两端精简历史列表和状态,但只为新增业务单补读详情,不会重复逐张下载已索引发票。任一来源异常、历史被上游截断或详情读取失败都会把覆盖标记为不完整,不会静默返回“无重复”。 发票指纹以服务器密钥进行 HMAC,另以用户 HMAC 分区。SQLite 只保存用户 HMAC、发票 HMAC、匹配类型、来源、上游业务单号、规范化状态和时间;不保存发票原号、审批正文、费用详情、商户、附件或上游 Schema。指纹、最小审批索引和非活跃扫描水位最多保留 35 天,网关启动及每次同步时自动清理,删除审批索引会级联删除全部关联指纹。活跃确认还为每个发票 HMAC 建立 10 分钟唯一预留,到期自动删除,因此两个 Agent 会话不能同时为同一用户、同一发票取得可提交确认。历史重复或覆盖不完整只能在用户明确确认后继续,该决定和预留均绑定到一次性 `confirmationId`。 diff --git a/src/xft-open-api.ts b/src/xft-open-api.ts index bfacdd5..b76492b 100644 --- a/src/xft-open-api.ts +++ b/src/xft-open-api.ts @@ -31,6 +31,10 @@ import { SignedReferenceCodec } from "./signed-reference.js"; type FetchLike = typeof fetch; type JsonObject = Record; const { sm2, sm3, sm4 } = smCrypto; +const TASK_DETAIL_BATCH_LIMIT = 300; +const TASK_LOOKUP_PAGE_SIZE = 100; +// Preserve the previous 100 pages × 1000 records ownership scan budget. +const TASK_LOOKUP_MAX_PAGES = 100_000 / TASK_LOOKUP_PAGE_SIZE; interface XftIdentity { enterpriseUserId: string; @@ -993,8 +997,8 @@ export class XftOpenApiClient implements XftApprovalProvider { } private async findHandledHistory(identity: XftIdentity, billId: string): Promise { - const pageSize = 1000; - for (let currentPage = 1; currentPage <= 100; currentPage += 1) { + const pageSize = TASK_LOOKUP_PAGE_SIZE; + for (let currentPage = 1; currentPage <= TASK_LOOKUP_MAX_PAGES; currentPage += 1) { const page = await this.loadTaskPage(identity, currentPage, pageSize, "done", billId); const found = page.items.find((item) => item.billId === billId); if (found) return toHistoryItem(found, "handled"); @@ -1070,19 +1074,21 @@ export class XftOpenApiClient implements XftApprovalProvider { ); const pageRecords = objectRecords(page); if (pageRecords.length === 0) return { items: [] as CurrentTodo[], pageRecordCount: 0 }; - const todoTaskIds = pageRecords.flatMap((record) => { + const todoTaskIds = uniqueStrings(pageRecords.flatMap((record) => { const value = stringAt(record, ["todoTaskId"]); return value ? [value] : []; - }); - const detailResult = todoTaskIds.length - ? await this.request( - "/xftmsg/msg/xft-message/v1/todo-task/detail/by-ids", - { todoTaskIds }, - "POST", - identity, - ) - : []; - const detailed = objectRecords(detailResult); + })); + const detailed: JsonObject[] = []; + for (let offset = 0; offset < todoTaskIds.length; offset += TASK_DETAIL_BATCH_LIMIT) { + const detailResult = await this.request( + "/xftmsg/msg/xft-message/v1/todo-task/detail/by-ids", + { todoTaskIds: todoTaskIds.slice(offset, offset + TASK_DETAIL_BATCH_LIMIT) }, + "POST", + identity, + ); + // A failed batch must propagate; partial details cannot prove ownership. + detailed.push(...objectRecords(detailResult)); + } const byTaskId = new Map(detailed.map((record) => [stringAt(record, ["todoTaskId"]) ?? "", record])); const todoRecords = pageRecords.map((record) => ({ ...record, ...(byTaskId.get(stringAt(record, ["todoTaskId"]) ?? "") ?? {}) })); const selectedRecords = billId @@ -1131,8 +1137,8 @@ export class XftOpenApiClient implements XftApprovalProvider { } private async findCurrentTodo(identity: XftIdentity, billId: string): Promise { - const pageSize = 1000; - for (let currentPage = 1; currentPage <= 100; currentPage += 1) { + const pageSize = TASK_LOOKUP_PAGE_SIZE; + for (let currentPage = 1; currentPage <= TASK_LOOKUP_MAX_PAGES; currentPage += 1) { const page = await this.loadTodoPage(identity, currentPage, pageSize, billId); if (page.items[0]) return page.items[0]; if (page.pageRecordCount < pageSize) return undefined; diff --git a/test/xft-open-api.test.ts b/test/xft-open-api.test.ts index 4339d6a..70ae434 100644 --- a/test/xft-open-api.test.ts +++ b/test/xft-open-api.test.ts @@ -3,6 +3,10 @@ import test from "node:test"; import smCrypto from "sm-crypto"; import type { AppConfig } from "../src/config.js"; import { XftOpenApiClient } from "../src/xft-open-api.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import type { ApprovalService } from "../src/approval-service.js"; +import { createApprovalMcpServer } from "../src/server.js"; const privateKey = "1".padStart(64, "0"); const { sm3, sm4 } = smCrypto; @@ -1270,3 +1274,127 @@ test("Xft OpenAPI never calls an action endpoint for a bill outside the user's l assert.equal(result.failed, 1); assert.equal(actionCalls, 0); }); + +// All regression reads enter through MCP; only the upstream HTTP service is mocked. +async function withTaskQueryMcp(fakeFetch: typeof fetch, run: (client: Client) => Promise) { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createApprovalMcpServer(config(), {} as ApprovalService, new XftOpenApiClient(config(), fakeFetch)); + const client = new Client({ name: "xft-task-query-test", version: "1.0.0" }); + await server.connect(serverTransport); + try { + await client.connect(clientTransport); + await run(client); + } finally { + await client.close(); + await server.close(); + } +} + +function taskQueryFixture(count: number, targetIndex = Math.min(count - 1, 499)) { + const records = Array.from({ length: count }, (_, index) => ({ todoTaskId: `SYNTHETIC-${index}` })); + const details = records.map((record, index) => ({ + ...record, businessId: String(90000000000 + index), appCode: "xft-itrip", + dealTime: "2026-08-14T04:00:00.000Z", + })); + const calls: Array<{ path: string; body: Record }> = []; + const state = { lookup: false, absent: false, failBatch: 0, batches: 0, duplicate: false }; + const fetcher: typeof fetch = async (input, init) => { + const path = new URL(String(input)).pathname; + const body = init?.body ? decodedBody(init) : {}; + calls.push({ path, body }); + if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [ + { thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }, + ] } }); + if (path.endsWith("todo-task/page/by-user")) { + // Oversized list responses also exercise the hard cap at the detail boundary. + const start = state.lookup ? (Number(body.currentPage) - 1) * Number(body.pageSize) : 0; + const page = state.lookup ? records.slice(start, start + Number(body.pageSize)) : records; + return encrypted({ code: "0", data: { records: state.absent ? [] + : state.duplicate ? [...page, ...page.slice(0, 20)] : page } }); + } + if (path.endsWith("todo-task/detail/by-ids")) { + state.batches += 1; + const ids = body.todoTaskIds as string[]; + assert.ok(ids.length <= 300); + if (state.batches === state.failBatch) return encrypted({ code: "XFTBIZ0001", message: "synthetic batch failure" }); + // Reverse order to ensure enrichment joins by ID, not response position. + return encrypted({ code: "0", data: details.filter((item) => ids.includes(item.todoTaskId)).reverse() }); + } + if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: (body.billIds as Array).map((id) => ({ + billId: String(id), eventType: "COMMON_REIM", + title: String(id) === String(90000000000 + targetIndex) ? "目标报销" : "其他报销", + applyTime: "2026-08-14T04:00:00.000Z", + })) } }); + if (path.includes("/detail")) return encrypted({ code: "0", data: { billRemark: "目标报销" } }); + throw new Error(`Unexpected upstream operation: ${path}`); + }; + return { calls, state, fetcher }; +} + +const taskHistoryArguments = { + source: "xft", scope: "handled", query: "目标报销", limit: 20, + startDate: "2026-08-01", endDate: "2026-08-31", +}; + +for (const count of [300, 301, 1000]) { + test(`MCP XFT history batches ${count} task IDs and joins all detail batches`, async () => { + const fixture = taskQueryFixture(count); + fixture.state.duplicate = count === 301; + await withTaskQueryMcp(fixture.fetcher, async (client) => { + const response = await client.callTool({ name: "approval_list_history", arguments: taskHistoryArguments }); + assert.notEqual(response.isError, true); + const result = (response.structuredContent as any).result; + assert.equal(result.sourceStatus.xft.status, "ok"); + assert.equal(result.items.length, 1); + assert.equal(result.items[0].title, "目标报销"); + const batches = fixture.calls.filter((call) => call.path.endsWith("detail/by-ids")) + .map((call) => call.body.todoTaskIds as string[]); + assert.deepEqual(batches.map((batch) => batch.length), count === 300 ? [300] : count === 301 ? [300, 1] : [300, 300, 300, 100]); + assert.equal(new Set(batches.flat()).size, count); + }); + }); +} + +test("MCP XFT history reports a later batch failure without returning partial success or retrying", async () => { + const fixture = taskQueryFixture(1000); + fixture.state.failBatch = 2; + await withTaskQueryMcp(fixture.fetcher, async (client) => { + const response = await client.callTool({ name: "approval_list_history", arguments: taskHistoryArguments }); + const result = (response.structuredContent as any).result; + assert.equal(result.sourceStatus.xft.status, "unavailable"); + assert.deepEqual(result.items, []); + assert.equal(fixture.state.batches, 2); + assert.equal(fixture.calls.some((call) => call.path.endsWith("bill-list")), false); + }); +}); + +for (const scope of ["todo", "done"]) { + for (const targetIndex of [0, 100, 199]) { + test(`MCP XFT ${scope} single detail finds index ${targetIndex} with small pages and stops early`, async () => { + const fixture = taskQueryFixture(250, targetIndex); + await withTaskQueryMcp(fixture.fetcher, async (client) => { + const listed = await client.callTool(scope === "done" + ? { name: "approval_list_history", arguments: taskHistoryArguments } + : { name: "approval_list_todos", arguments: { source: "xft", query: "目标报销", limit: 20 } }); + const approvalId = (listed.structuredContent as any).result.items[0].approvalId; + fixture.calls.length = 0; + fixture.state.lookup = true; + const response = await client.callTool({ name: "approval_get_detail", arguments: { approvalId } }); + assert.notEqual(response.isError, true); + const pages = fixture.calls.filter((call) => call.path.endsWith("page/by-user")); + assert.deepEqual(pages.map((call) => call.body), Array.from({ length: Math.floor(targetIndex / 100) + 1 }, (_, index) => ({ + currentPage: index + 1, dealStatus: scope, pageSize: 100, + }))); + const summaries = fixture.calls.filter((call) => call.path.endsWith("bill-list")); + assert.equal(summaries.length, 1); + assert.deepEqual(summaries[0]!.body.billIds, [90000000000 + targetIndex]); + // Removing the user's live task must invalidate the previously issued reference. + fixture.state.absent = true; + fixture.calls.length = 0; + const missing = await client.callTool({ name: "approval_get_detail", arguments: { approvalId } }); + assert.equal(missing.isError, true); + assert.equal(fixture.calls.some((call) => call.path.includes("/detail")), false); + }); + }); + } +}