feat: initial public release
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s

This commit is contained in:
2026-08-17 03:04:09 +00:00
commit 504145aec6
62 changed files with 18318 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { parseArgs } from "node:util";
import { isDirectEntrypoint } from "./entrypoint.js";
export const SERVICE_NAME = "dingtalk-approval-mcp.service";
interface Paths {
root: string;
env: string;
unit: string;
refSecret: string;
sessionSecret: string;
authDatabase: string;
}
type Systemctl = (args: string[], tolerateFailure?: boolean) => void;
export function servicePaths(projectRoot: string, env: NodeJS.ProcessEnv = process.env): Paths {
const root = resolve(projectRoot);
const configHome = resolve(env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config"));
return {
root,
env: join(root, ".runtime", "dingtalk-mcp.env"),
unit: join(configHome, "systemd", "user", SERVICE_NAME),
refSecret: join(root, ".secrets", "approval-ref.key"),
sessionSecret: join(root, ".secrets", "oauth-session.key"),
authDatabase: join(root, ".runtime", "auth.sqlite"),
};
}
export function configureService(options: {
projectRoot: string;
publicBaseUrl: string;
corpId: string;
port: number;
credentialsFile: string;
xftOpenApiAppId?: string;
xftOpenApiSecretFile?: string;
xftOpenApiEnterpriseId?: string;
xftOpenApiBaseUrl?: string;
xftOpenApiEncrypted?: boolean;
dingtalkClientOnlyProcessCodes?: string;
}): Paths {
const paths = servicePaths(options.projectRoot);
const baseUrl = normalizePublicBaseUrl(options.publicBaseUrl);
if (!options.corpId.trim()) throw new Error("必须配置钉钉 CorpId");
const credentials = projectPath(paths.root, options.credentialsFile);
assertPrivateFile(credentials, "钉钉凭证文件");
ensureSecret(paths.refSecret);
ensureSecret(paths.sessionSecret);
mkdirSync(dirname(paths.authDatabase), { recursive: true, mode: 0o700 });
chmodSync(dirname(paths.authDatabase), 0o700);
const values: Array<[string, string]> = [
["APP_MODE", "gateway"],
["DINGTALK_CREDENTIALS_FILE", credentials],
["DINGTALK_CORP_ID", options.corpId.trim()],
["MCP_PUBLIC_BASE_URL", baseUrl],
["MCP_REMOTE_AUTH_ENABLED", "true"],
["MCP_BIND_HOST", "127.0.0.1"],
["PORT", String(validPort(options.port))],
["MCP_AUTH_DATABASE", paths.authDatabase],
["MCP_SESSION_SECRET_FILE", paths.sessionSecret],
["MCP_APPROVAL_REF_SECRET_FILE", paths.refSecret],
];
if (options.dingtalkClientOnlyProcessCodes?.trim()) {
values.push(["DINGTALK_CLIENT_ONLY_PROCESS_CODES", options.dingtalkClientOnlyProcessCodes.trim()]);
}
if (options.xftOpenApiAppId || options.xftOpenApiSecretFile || options.xftOpenApiEnterpriseId) {
if (!options.xftOpenApiAppId?.trim() || !options.xftOpenApiSecretFile?.trim() || !options.xftOpenApiEnterpriseId?.trim()) {
throw new Error("薪福通正式 API 必须同时配置 AppId、AuthoritySecret 文件和企业号");
}
const secretFile = projectPath(paths.root, options.xftOpenApiSecretFile);
assertPrivateFile(secretFile, "薪福通 AuthoritySecret 文件");
values.push(
["XFT_OPEN_API_ENABLED", "true"],
["XFT_OPEN_API_APP_ID", options.xftOpenApiAppId.trim()],
["XFT_OPEN_API_AUTHORITY_SECRET_FILE", secretFile],
["XFT_OPEN_API_ENTERPRISE_ID", options.xftOpenApiEnterpriseId.trim()],
["XFT_OPEN_API_ENCRYPTED", String(options.xftOpenApiEncrypted ?? true)],
);
if (options.xftOpenApiBaseUrl?.trim()) values.push(["XFT_OPEN_API_BASE_URL", options.xftOpenApiBaseUrl.trim()]);
} else throw new Error("薪福通安全网关必须配置正式 OpenAPI AppId、AuthoritySecret 文件和企业号");
values.push(["YIDA_ENABLED", "false"]);
mkdirSync(dirname(paths.env), { recursive: true, mode: 0o700 });
chmodSync(dirname(paths.env), 0o700);
const temporary = `${paths.env}.tmp-${process.pid}`;
writeFileSync(temporary, values.map(([key, value]) => `${key}=${JSON.stringify(value)}\n`).join(""), { mode: 0o600 });
chmodSync(temporary, 0o600);
renameSync(temporary, paths.env);
chmodSync(paths.env, 0o600);
return paths;
}
export function renderUnit(paths: Paths, nodePath = process.execPath): string {
return `[Unit]\nDescription=DingTalk approval remote MCP\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nWorkingDirectory=${unitPath(paths.root)}\nEnvironmentFile=${unitPath(paths.env)}\nExecStart=${quote(nodePath)} ${quote(join(paths.root, "dist", "src", "index.js"))}\nRestart=on-failure\nRestartSec=5\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\nReadWritePaths=${unitPath(join(paths.root, ".runtime"))}\n\n[Install]\nWantedBy=default.target\n`;
}
export function startService(projectRoot: string, systemctl: Systemctl = runSystemctl): string {
const paths = servicePaths(projectRoot);
if (!existsSync(paths.env)) throw new Error("缺少运行配置,请先执行 service configure");
if (!existsSync(join(paths.root, "dist", "src", "index.js"))) throw new Error("缺少构建产物,请先执行 npm run build");
mkdirSync(dirname(paths.unit), { recursive: true });
writeFileSync(paths.unit, renderUnit(paths), "utf8");
systemctl(["daemon-reload"]);
systemctl(["enable", SERVICE_NAME]);
systemctl(["restart", SERVICE_NAME]);
return showConfig(projectRoot);
}
export function uninstallService(projectRoot: string, systemctl: Systemctl = runSystemctl): void {
const paths = servicePaths(projectRoot);
systemctl(["disable", "--now", SERVICE_NAME], true);
if (existsSync(paths.unit)) rmSync(paths.unit);
if (existsSync(paths.env)) rmSync(paths.env);
systemctl(["daemon-reload"], true);
systemctl(["reset-failed", SERVICE_NAME], true);
}
export function showConfig(projectRoot: string): string {
const values = readEnv(servicePaths(projectRoot).env);
const base = normalizePublicBaseUrl(values.MCP_PUBLIC_BASE_URL ?? "");
return `MCP URL: ${base}/mcp\nOAuth issuer: ${base}\nHealth (local): http://127.0.0.1:${values.PORT ?? "3000"}/healthz\n`;
}
function ensureSecret(path: string): void {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
chmodSync(dirname(path), 0o700);
if (!existsSync(path)) writeFileSync(path, `${randomBytes(32).toString("base64url")}\n`, { mode: 0o600 });
chmodSync(path, 0o600);
}
function readEnv(path: string): Record<string, string> {
if (!existsSync(path)) throw new Error(`缺少运行配置:${path}`);
return Object.fromEntries(readFileSync(path, "utf8").split(/\r?\n/).flatMap((line) => {
const separator = line.indexOf("=");
if (separator <= 0) return [];
const key = line.slice(0, separator);
const raw = line.slice(separator + 1);
try { return [[key, JSON.parse(raw) as string]]; } catch { return [[key, raw]]; }
}));
}
function runSystemctl(args: string[], tolerateFailure = false): void {
try { execFileSync("systemctl", ["--user", ...args], { stdio: tolerateFailure ? "ignore" : "inherit" }); }
catch (error) { if (!tolerateFailure) throw error; }
}
function assertPrivateFile(path: string, label: string): void {
let stat;
try { stat = statSync(path); } catch { throw new Error(`${label}不存在:${path}`); }
if (!stat.isFile()) throw new Error(`${label}不是普通文件:${path}`);
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) throw new Error(`${label}权限必须为 600`);
}
function normalizePublicBaseUrl(value: string): string {
const normalized = value.trim().replace(/\/$/, "");
const parsed = new URL(normalized);
if (parsed.protocol !== "https:") throw new Error("公网 MCP 地址必须使用 HTTPS");
if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) {
throw new Error("公网 MCP 地址只能包含 https://域名[:端口]");
}
return normalized;
}
function validPort(value: number): number { if (!Number.isSafeInteger(value) || value < 1 || value > 65535) throw new Error("端口无效"); return value; }
function projectPath(root: string, value: string): string { return resolve(root, value); }
function unitPath(value: string): string { return value.replace(/\\/g, "\\\\").replace(/ /g, "\\x20").replace(/\t/g, "\\t"); }
function quote(value: string): string { return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; }
function usage(): string { return "dingtalk-admin service configure|start|restart|stop|status|show-config|uninstall [options]"; }
export function main(argv = process.argv.slice(2)): number {
const [group, action, ...rest] = argv;
if (group !== "service" || !action || argv.includes("--help")) { console.log(usage()); return argv.includes("--help") || argv.length === 0 ? 0 : 1; }
const common = { "project-root": { type: "string" as const, default: process.cwd() } };
if (action === "configure") {
const { values } = parseArgs({ args: rest, strict: true, options: {
...common,
"public-base-url": { type: "string" }, "corp-id": { type: "string" }, port: { type: "string", default: "3000" },
"credentials-file": { type: "string", default: ".secrets/dingtalk.json" },
"xft-open-api-app-id": { type: "string" }, "xft-open-api-secret-file": { type: "string" },
"xft-open-api-enterprise-id": { type: "string" }, "xft-open-api-base-url": { type: "string" },
"xft-open-api-unencrypted": { type: "boolean" }, "dingtalk-client-only-process-codes": { type: "string" },
}});
if (!values["public-base-url"] || !values["corp-id"]) throw new Error("缺少 --public-base-url 或 --corp-id");
const paths = configureService({
projectRoot: String(values["project-root"]), publicBaseUrl: String(values["public-base-url"]),
corpId: String(values["corp-id"]), port: Number(values.port), credentialsFile: String(values["credentials-file"]),
...(values["xft-open-api-app-id"] ? { xftOpenApiAppId: String(values["xft-open-api-app-id"]) } : {}),
...(values["xft-open-api-secret-file"] ? { xftOpenApiSecretFile: String(values["xft-open-api-secret-file"]) } : {}),
...(values["xft-open-api-enterprise-id"] ? { xftOpenApiEnterpriseId: String(values["xft-open-api-enterprise-id"]) } : {}),
...(values["xft-open-api-base-url"] ? { xftOpenApiBaseUrl: String(values["xft-open-api-base-url"]) } : {}),
xftOpenApiEncrypted: !values["xft-open-api-unencrypted"],
...(values["dingtalk-client-only-process-codes"] ? { dingtalkClientOnlyProcessCodes: String(values["dingtalk-client-only-process-codes"]) } : {}),
});
console.log(`运行配置已写入 ${paths.env}(0600)`); console.log(showConfig(paths.root)); return 0;
}
const { values } = parseArgs({ args: rest, strict: true, options: common });
const root = String(values["project-root"]);
if (action === "start") console.log(startService(root));
else if (action === "restart") { runSystemctl(["restart", SERVICE_NAME]); console.log("MCP service restarted."); }
else if (action === "stop") { runSystemctl(["stop", SERVICE_NAME]); console.log("MCP service stopped."); }
else if (action === "status") runSystemctl(["status", SERVICE_NAME]);
else if (action === "show-config") console.log(showConfig(root));
else if (action === "uninstall") { uninstallService(root); console.log("MCP service uninstalled; secrets and OAuth database were preserved."); }
else { console.error(usage()); return 1; }
return 0;
}
if (isDirectEntrypoint(import.meta.url, process.argv[1])) {
try { process.exitCode = main(); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }
}
+1
View File
@@ -0,0 +1 @@
export const APPROVAL_BATCH_LIMIT = 20;
+661
View File
@@ -0,0 +1,661 @@
import { extractApprovalListMetadata, normalizeLabel, sensitiveLabel } from "./approval-metadata.js";
import { isRecord } from "./json.js";
export type ApprovalDetailView = "summary" | "expense" | "raw";
export interface ApprovalDetailOptions {
view?: ApprovalDetailView;
rawPath?: string;
rawCursor?: number;
rawLimit?: number;
rawQuery?: string;
}
export interface ExpenseItem {
occurredOn: string | null;
expenseType: string | null;
amount: number | null;
currency: string;
description: string | null;
merchant: string | null;
location: string | null;
customer: string | null;
contacts: string[];
relatedTripId: string | null;
invoiceDates: string[];
projectId: string | null;
crmRecordId: string | null;
}
interface LabeledValue { label: string; value: unknown }
const MAX_FORM_FIELDS = 120;
const MAX_EXPENSE_ITEMS = 200;
const MAX_FIELD_TEXT = 1_000;
const MAX_RAW_SCALAR_TEXT = 4_000;
export function compactApprovalDetail(raw: unknown, view: Exclude<ApprovalDetailView, "raw">) {
const metadata = extractApprovalListMetadata(...detailMetadataSources(raw));
const xftRows = collectXftExpenseRows(raw);
const fields = uniqueLabeledValues([
...collectOfficialXftFormFields(raw),
...collectLabeledValues(raw),
...xftRows.flat(),
]);
const expenseItems = extractExpenseItemsFrom(raw, xftRows);
const summedAmount = sumExpenseAmounts(expenseItems);
const detail: Record<string, unknown> = {
applicant: {
...(metadata.applicantName ? { name: metadata.applicantName } : {}),
...(metadata.applicantUserId ? { userId: metadata.applicantUserId } : {}),
...(metadata.applicantDepartment ? { department: metadata.applicantDepartment } : {}),
},
...(metadata.submittedAt ? { submittedAt: metadata.submittedAt } : {}),
...(metadata.submittedAt ? localTimeFields(metadata.submittedAt) : {}),
...(metadata.amount !== undefined
? { amount: { value: metadata.amount, currency: metadata.currency ?? "CNY" }, amountSource: "form_total" }
: summedAmount ? { amount: summedAmount.amount, amountSource: "expense_items_sum" } : {}),
...(metadata.summary ? { summary: metadata.summary } : {}),
expenseItems,
};
detail.formFields = fields.slice(0, MAX_FORM_FIELDS).map((field) => ({
label: field.label,
value: field.display,
}));
detail.formFieldsTruncated = fields.length > MAX_FORM_FIELDS;
if (view === "expense") {
detail.supportingFields = fields
.filter((field) => /(发票|开票|付款主体|支付主体|收款主体|费用承担|成本中心|项目|客户|事由|日期|地址)/.test(normalizeLabel(field.label)))
.slice(0, 50)
.map((field) => ({ label: field.label, value: field.display }));
}
return {
detail,
diagnostics: {
realtime: true,
rawAvailable: true,
rawOmitted: true,
rawAccess: "使用 view=raw,并通过 rawPath、rawCursor、rawLimit 分页浏览;单次不会返回完整大对象。",
expenseItemsExtracted: expenseItems.length,
},
};
}
function localTimeFields(value: string): Record<string, string> {
const time = Date.parse(value);
if (!Number.isFinite(time)) return {};
return {
submittedAtLocal: new Date(time + 8 * 3_600_000).toISOString().replace("Z", "+08:00"),
submittedTimeZone: "Asia/Shanghai",
};
}
export function browseRawDetail(
raw: unknown,
rawPath = "",
rawCursor = 0,
rawLimit = 20,
rawQuery = "",
) {
const resolved = resolveJsonPointer(raw, rawPath);
const limit = Math.max(1, Math.min(50, Math.floor(rawLimit)));
const cursor = Math.max(0, Math.floor(rawCursor));
const container = parseJsonContainer(resolved.value);
const value = container.value;
if (rawQuery.trim()) return searchRawDetail(value, rawPath, rawQuery, cursor, limit, resolved.encodedAsJsonString || container.parsed);
if (Array.isArray(value)) {
const end = Math.min(value.length, cursor + limit);
return rawPage(rawPath, resolved.encodedAsJsonString || container.parsed, cursor, end, value.length,
value.slice(cursor, end).map((item, index) => rawEntry(String(cursor + index), item, pointerJoin(rawPath, String(cursor + index)))));
}
if (isRecord(value)) {
const entries = visibleObjectEntries(value);
const end = Math.min(entries.length, cursor + limit);
return rawPage(rawPath, resolved.encodedAsJsonString || container.parsed, cursor, end, entries.length,
entries.slice(cursor, end).map(([key, item]) => rawEntry(key, item, pointerJoin(rawPath, key))));
}
return {
path: rawPath,
kind: scalarKind(value),
...scalarPreview(value),
diagnostics: { realtime: true, bounded: true, filteredForAgent: true, maxScalarCharacters: MAX_RAW_SCALAR_TEXT },
};
}
function sumExpenseAmounts(items: ExpenseItem[]): { amount: { value: number; currency: string } } | undefined {
if (items.length === 0 || items.some((item) => item.amount === null)) return undefined;
const currencies = [...new Set(items.map((item) => item.currency))];
if (currencies.length !== 1) return undefined;
const cents = items.reduce((total, item) => total + Math.round(item.amount! * 100), 0);
return { amount: { value: cents / 100, currency: currencies[0]! } };
}
function searchRawDetail(
root: unknown,
basePath: string,
query: string,
cursor: number,
limit: number,
encodedAsJsonString: boolean,
) {
const needle = query.trim().toLocaleLowerCase();
const matches: Array<Record<string, unknown>> = [];
const seen = new WeakSet<object>();
let visited = 0;
let truncated = false;
const MAX_VISITED = 20_000;
const MAX_MATCHES = 500;
const walk = (input: unknown, path: string, depth: number): void => {
if (depth > 16 || visited >= MAX_VISITED || matches.length >= MAX_MATCHES) { truncated = true; return; }
const parsed = parseJsonContainer(input);
const value = parsed.value;
if (value && typeof value === "object") {
if (seen.has(value)) return;
seen.add(value);
}
visited += 1;
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index += 1) walk(value[index], pointerJoin(path, String(index)), depth + 1);
return;
}
if (!isRecord(value)) return;
if (sensitiveLabel(fieldLabel(value) ?? "")) return;
const label = fieldLabel(value);
const fieldValue = fieldRawValue(value);
if (label?.toLocaleLowerCase().includes(needle)) {
matches.push({ path, key: path.split("/").at(-1) ?? "", label, ...(fieldValue !== undefined ? { valuePreview: compactDisplayValue(fieldValue) } : {}) });
}
for (const [key, child] of visibleObjectEntries(value)) {
const childPath = pointerJoin(path, key);
if (key.toLocaleLowerCase().includes(needle) && !label) {
matches.push({ path: childPath, key, ...scalarPreviewForSearch(child) });
} else if ((typeof child === "string" || typeof child === "number" || typeof child === "boolean")
&& !label && String(child).toLocaleLowerCase().includes(needle) && !/^(?:value|content|displayValue|fieldValue|extValue)$/i.test(key)) {
matches.push({ path: childPath, key, ...scalarPreviewForSearch(child) });
}
if (matches.length >= MAX_MATCHES) { truncated = true; break; }
walk(child, childPath, depth + 1);
}
};
walk(root, basePath, 0);
const end = Math.min(matches.length, cursor + limit);
return {
path: basePath,
kind: "search",
query,
encodedAsJsonString,
cursor,
returned: Math.max(0, end - cursor),
total: matches.length,
nextCursor: end < matches.length ? end : null,
matches: matches.slice(cursor, end),
diagnostics: { realtime: true, bounded: true, filteredForAgent: true, visitedNodes: visited, searchTruncated: truncated, maxVisitedNodes: MAX_VISITED, maxMatches: MAX_MATCHES },
};
}
function scalarPreviewForSearch(value: unknown): Record<string, unknown> {
const displayed = compactDisplayValue(value);
return displayed === undefined ? {} : { valuePreview: typeof displayed === "string" ? displayed.slice(0, 500) : displayed };
}
export function extractExpenseItems(raw: unknown): ExpenseItem[] {
return extractExpenseItemsFrom(raw, collectXftExpenseRows(raw));
}
function extractExpenseItemsFrom(raw: unknown, xftRows: LabeledValue[][]): ExpenseItem[] {
const candidates: LabeledValue[][] = [...xftRows];
const seenObjects = new WeakSet<object>();
let visited = 0;
const walk = (value: unknown, parentLabel = "", depth = 0): void => {
if (depth > 16 || visited > 75_000) return;
const parsed = parseJsonContainer(value);
value = parsed.value;
if (!value || typeof value !== "object") return;
if (seenObjects.has(value)) return;
seenObjects.add(value);
visited += 1;
if (Array.isArray(value)) {
const direct = labeledArray(value);
if (direct.length >= 2 && looksLikeExpenseRow(direct)) candidates.push(direct);
for (const item of value) walk(item, parentLabel, depth + 1);
return;
}
const record = value as Record<string, unknown>;
const label = fieldLabel(record) ?? parentLabel;
for (const key of ["rowValue", "rowValues", "fields", "fieldValues", "values"]) {
const row = Array.isArray(record[key]) ? labeledArray(record[key] as unknown[]) : [];
if (row.length >= 2 && looksLikeExpenseRow(row)) candidates.push(row);
}
const direct = directRecordFields(record);
if (direct.length >= 2 && looksLikeExpenseRow(direct)) candidates.push(direct);
for (const [key, child] of Object.entries(record)) {
if (isHeavyBinaryField(key, label)) continue;
walk(child, label || key, depth + 1);
}
};
walk(raw);
const normalized = candidates.map(normalizeExpenseItem).filter(hasExpenseIdentity);
const unique = new Map<string, ExpenseItem>();
for (const item of normalized) {
const key = JSON.stringify(item);
if (!unique.has(key)) unique.set(key, item);
if (unique.size >= MAX_EXPENSE_ITEMS) break;
}
return [...unique.values()];
}
function collectXftExpenseRows(raw: unknown): LabeledValue[][] {
const rows: LabeledValue[][] = [];
const seen = new WeakSet<object>();
let visited = 0;
const walk = (value: unknown, depth = 0): void => {
if (!value || typeof value !== "object" || depth > 12 || visited++ > 40_000 || seen.has(value)) return;
seen.add(value);
if (Array.isArray(value)) {
for (const child of value) walk(child, depth + 1);
return;
}
const record = value as Record<string, unknown>;
const expenseList = [record.expenseList, record.expList, record.applyExpenseDtoList].find(Array.isArray);
if (expenseList) {
for (const entry of expenseList) {
const row = xftExpenseRow(entry);
if (row.length) rows.push(row);
}
}
for (const [key, child] of Object.entries(record)) {
if (/businessConfig$/i.test(key) || isHeavyBinaryField(key, "")) continue;
walk(child, depth + 1);
}
};
walk(raw);
return rows;
}
function xftExpenseRow(value: unknown): LabeledValue[] {
if (!isRecord(value)) return [];
const officialFields = officialXftExpenseFields(value);
const values = isRecord(value.values) ? value.values : {};
const config = isRecord(value.businessConfigDTO) ? value.businessConfigDTO : undefined;
const expenseSchema = isRecord(config?.expenseSchema) ? config.expenseSchema : undefined;
const schemaBody = Array.isArray(expenseSchema?.body) ? expenseSchema.body : [];
const fields: LabeledValue[] = [];
const schemaFields: LabeledValue[] = [];
for (const schemaValue of schemaBody) {
if (!isRecord(schemaValue)) continue;
const key = typeof schemaValue.key === "string" ? schemaValue.key : undefined;
const label = typeof schemaValue.name === "string" ? schemaValue.name : undefined;
const holder = key ? values[key] : undefined;
const fieldValue = isRecord(holder) && "value" in holder ? holder.value : holder;
if (key && label && fieldValue !== undefined && !heavyValue(label, fieldValue) && !sensitiveLabel(label)) {
schemaFields.push({ label, value: fieldValue });
}
}
const businessData = isRecord(value.businessDataDTO) ? value.businessDataDTO : undefined;
const body = isRecord(businessData?.expBodyInsDto) ? businessData.expBodyInsDto : undefined;
if (body) {
addField(fields, "费用类型", body.expNam);
addField(fields, "费用发生日期", body.wrTim);
addField(fields, "实际报销金额", body.reimAmt ?? body.expenseTotalAmount);
addField(fields, "币种", body.currencyCode ?? (isRecord(body.expenseTotalAmountDTO) ? body.expenseTotalAmountDTO.currencyCode : undefined));
addField(fields, "费用说明", body.remark);
const expenseJson = isRecord(body.expJsonObject) ? body.expJsonObject : undefined;
addBusinessField(fields, "商户", expenseJson?.merchantNam);
addBusinessField(fields, "消费地点", expenseJson?.payCity ?? expenseJson?.destination);
addBusinessField(fields, "费用发生日期", expenseJson?.bgnTim ?? expenseJson?.wrTim);
const certificate = isRecord(body.certificate) ? body.certificate : undefined;
const invoices = Array.isArray(certificate?.invoiceCloudCertificates) ? certificate.invoiceCloudCertificates : [];
const invoiceDates = invoices.flatMap((invoice) => isRecord(invoice) && invoice.invoiceDate !== undefined ? [invoice.invoiceDate] : []);
if (invoiceDates.length) fields.push({ label: "发票日期", value: invoiceDates });
}
return [...officialFields, ...schemaFields, ...fields];
}
function officialXftExpenseFields(row: Record<string, unknown>): LabeledValue[] {
if (!("expTypeName" in row || "expNam" in row || "reimAmount" in row || "reimAmt" in row
|| "applyAmount" in row || "expAttribute" in row || "invoiceInfoList" in row)) return [];
const fields: LabeledValue[] = [];
const invoices = Array.isArray(row.invoiceInfoList) ? row.invoiceInfoList.filter(isRecord) : [];
const firstInvoiceDate = invoices.find((invoice) => present(invoice.invoiceDate))?.invoiceDate;
addField(fields, "费用发生日期", row.wrTim ?? row.occurrenceDate ?? row.bgnTim ?? row.checkInDate ?? firstInvoiceDate);
addField(fields, "费用类型", row.expTypeName ?? row.expNam);
addField(fields, "实际报销金额", row.reimAmount ?? row.originalCurrencyReimAmt ?? row.reimAmt ?? row.applyAmount);
addField(fields, "币种", row.currencyCode);
addField(fields, "费用说明", row.remark);
const sellerName = invoices.find((invoice) => present(invoice.sellerName))?.sellerName;
addBusinessField(fields, "商户", row.merchantName ?? row.merchantNam ?? sellerName);
const route = invoices.map((invoice) => [invoice.trafficFrom, invoice.trafficTo].filter(present).join(" → ")).find(Boolean);
addBusinessField(fields, "消费地点", row.payCity ?? row.payCityCode ?? row.destination ?? route);
const invoiceDates = invoices.flatMap((invoice) => present(invoice.invoiceDate) ? [invoice.invoiceDate] : []);
if (invoiceDates.length) fields.push({ label: "发票日期", value: invoiceDates });
appendOfficialCustomFields(fields, row.expCustomFieldList);
return fields;
}
function collectOfficialXftFormFields(raw: unknown): LabeledValue[] {
const root = isRecord(raw) ? raw : {};
const form = isRecord(root.form) ? root.form : undefined;
if (!form || !("eventType" in form || "expenseList" in form || "expTotalCnt" in form)) return [];
const fields: LabeledValue[] = [];
addField(fields, "单据类型", form.eventName ?? form.eventType);
addField(fields, "单据编号", form.billId ?? root.billId ?? (isRecord(root.todo) ? root.todo.approvalId : undefined));
addField(fields, "申请人", form.aplName ?? form.wrName);
addField(fields, "部门", form.aplDepName ?? form.billDepName);
addField(fields, "提交时间", form.applyTim);
addField(fields, "报销总额", form.expTotalCnt);
addField(fields, "事由", form.billRemark);
appendOfficialCustomFields(fields, form.billCustomFieldList);
return fields;
}
function appendOfficialCustomFields(fields: LabeledValue[], value: unknown): void {
if (!Array.isArray(value)) return;
for (const item of value) {
if (!isRecord(item)) continue;
const label = typeof item.fieldName === "string" ? item.fieldName.trim() : "";
const fieldValue = item.showValue ?? item.displayValue ?? item.value;
if (!label || !present(fieldValue) || sensitiveLabel(label) || heavyValue(label, fieldValue)) continue;
fields.push({ label, value: fieldValue });
}
}
function present(value: unknown): boolean {
return value !== undefined && value !== null && value !== "";
}
function uniqueLabeledValues(fields: LabeledValue[]): Array<LabeledValue & { display: string | number | boolean }> {
const unique = new Map<string, LabeledValue & { display: string | number | boolean }>();
for (const field of fields) {
const label = normalizeLabel(field.label);
if (!label || /^\[\s*\]$/.test(label)) continue;
const display = compactDisplayValue(field.value);
if (display === undefined) continue;
const key = `${label}\u0000${String(display)}`;
if (!unique.has(key)) unique.set(key, { ...field, display });
}
return [...unique.values()];
}
function addField(fields: LabeledValue[], label: string, value: unknown): void {
if (present(value)) fields.push({ label, value });
}
function addBusinessField(fields: LabeledValue[], label: string, value: unknown): void {
if (value === 0 || value === 1 || value === "0" || value === "1" || value === false || value === true) return;
addField(fields, label, value);
}
function detailMetadataSources(raw: unknown): unknown[] {
const record = isRecord(raw) ? raw : {};
const form = isRecord(record.form) ? record.form : undefined;
const businessData = isRecord(form?.businessData) ? form.businessData : undefined;
return [raw, record.todo, form, businessData?.expBillInsDto].filter((value) => value !== undefined);
}
function collectLabeledValues(raw: unknown): LabeledValue[] {
const output: LabeledValue[] = [];
const seenObjects = new WeakSet<object>();
let visited = 0;
const walk = (value: unknown, depth = 0): void => {
if (depth > 12 || visited > 30_000 || output.length >= MAX_FORM_FIELDS * 2) return;
value = parseJsonContainer(value).value;
if (!value || typeof value !== "object") return;
if (seenObjects.has(value)) return;
seenObjects.add(value);
visited += 1;
if (Array.isArray(value)) {
for (const child of value) walk(child, depth + 1);
return;
}
const record = value as Record<string, unknown>;
const label = fieldLabel(record);
const fieldValue = fieldRawValue(record);
if (label && fieldValue !== undefined && !sensitiveLabel(label) && !heavyValue(label, fieldValue)) {
output.push({ label, value: fieldValue });
}
for (const [key, child] of Object.entries(record)) {
if (/customFieldConfig$/i.test(key) || isHeavyBinaryField(key, label ?? "")) continue;
walk(child, depth + 1);
}
};
walk(raw);
// Deduplication happens in uniqueLabeledValues at the single call site.
return output;
}
function normalizeExpenseItem(fields: LabeledValue[]): ExpenseItem {
const values = new Map(fields.map((field) => [normalizeLabel(field.label), field.value]));
const find = (pattern: RegExp): unknown => [...values].find(([label]) => pattern.test(label))?.[1];
const occurredOn = dateOnly(find(/费用发生日期|实际发生日期|发生日期|消费日期|支出日期|交易日期/));
const amount = numberValue(find(/实际报销金额|报销金额|费用金额|申请金额|含税金额|^金额$/));
return {
occurredOn: occurredOn ?? null,
expenseType: textValue(find(/费用类型|费用项目|报销类型|支出类型|费用类别/)) ?? null,
amount: amount ?? null,
currency: currencyValue(find(/币种|货币/)) ?? "CNY",
description: textValue(find(/费用说明|报销说明|费用事由|申请事由|用途|摘要|备注/)) ?? null,
merchant: textValue(find(/商户|商家|供应商|销售方/)) ?? null,
location: textValue(find(/消费地点|发生地点|费用地点|消费城市|所在城市/)) ?? null,
customer: textValue(find(/接待客户|客户名称|拜访客户|^客户$/)) ?? null,
contacts: textList(find(/接待对象|客户联系人|联系人|参与人员|同行人员/)),
relatedTripId: textValue(find(/关联出差单|出差申请单|出差单号|关联出差/)) ?? null,
invoiceDates: dateList(find(/发票日期|开票日期/)),
projectId: textValue(find(/项目编号|项目号|项目ID|项目代码/)) ?? null,
crmRecordId: textValue(find(/CRM记录ID|CRMID|CRM编号|CRM记录/)) ?? null,
};
}
function hasExpenseIdentity(item: ExpenseItem): boolean {
return item.occurredOn !== null || item.expenseType !== null || item.amount !== null;
}
function looksLikeExpenseRow(fields: LabeledValue[]): boolean {
const labels = fields.map((field) => normalizeLabel(field.label)).join("|");
const score = [
/费用发生日期|实际发生日期|发生日期|消费日期/.test(labels),
/费用类型|费用项目|报销类型|费用类别/.test(labels),
/实际报销金额|报销金额|费用金额/.test(labels),
/费用说明|报销说明/.test(labels),
].filter(Boolean).length;
return score >= 2;
}
function labeledArray(values: unknown[]): LabeledValue[] {
return values.flatMap((value) => {
if (!isRecord(value)) return [];
const label = fieldLabel(value);
const fieldValue = fieldRawValue(value);
return label && fieldValue !== undefined ? [{ label, value: fieldValue }] : [];
});
}
function directRecordFields(record: Record<string, unknown>): LabeledValue[] {
return Object.entries(record).flatMap(([label, value]) => {
if (/^(id|key|uuid|componentType|bizAlias|rowValue|rowValues|fields|fieldValues|values)$/i.test(label)) return [];
if (value === undefined || value === null || typeof value === "object") return [];
return [{ label, value }];
});
}
function fieldLabel(record: Record<string, unknown>): string | undefined {
for (const key of ["label", "name", "title", "fieldName", "customFieldName", "fieldLabel", "bizAlias"]) {
if (typeof record[key] === "string" && record[key].trim()) return record[key].trim();
}
return undefined;
}
function fieldRawValue(record: Record<string, unknown>): unknown {
for (const key of ["value", "content", "displayValue", "fieldValue", "customFieldValue", "extValue"]) {
if (present(record[key])) return record[key];
}
return undefined;
}
function compactDisplayValue(value: unknown): string | number | boolean | undefined {
const parsed = parseJsonContainer(value).value;
if (typeof parsed === "number" || typeof parsed === "boolean") return parsed;
if (typeof parsed === "string") return parsed.slice(0, MAX_FIELD_TEXT);
if (Array.isArray(parsed)) {
const scalars = parsed.flatMap((item) => {
const displayed = compactDisplayValue(item);
return displayed === undefined || typeof displayed === "object" ? [] : [String(displayed)];
});
return scalars.length ? scalars.join("、").slice(0, MAX_FIELD_TEXT) : undefined;
}
if (isRecord(parsed)) {
for (const key of ["label", "name", "text", "displayValue", "value"]) {
const displayed = compactDisplayValue(parsed[key]);
if (displayed !== undefined) return displayed;
}
return undefined;
}
return undefined;
}
function resolveJsonPointer(root: unknown, path: string): { value: unknown; encodedAsJsonString: boolean } {
if (path && !path.startsWith("/")) throw new Error("rawPath 必须是 JSON Pointer(空字符串或以 / 开头)");
let value = root;
let encodedAsJsonString = false;
const segments = path ? path.slice(1).split("/").map(pointerDecode) : [];
for (const segment of segments) {
const parsed = parseJsonContainer(value);
value = parsed.value;
encodedAsJsonString ||= parsed.parsed;
if (Array.isArray(value)) {
if (!/^\d+$/.test(segment) || Number(segment) >= value.length) throw new Error(`rawPath 不存在:${path}`);
value = value[Number(segment)];
} else if (isRecord(value) && shouldExposeRawKey(segment, value) && Object.prototype.hasOwnProperty.call(value, segment)) {
value = value[segment];
} else throw new Error(`rawPath 不存在:${path}`);
}
return { value, encodedAsJsonString };
}
function rawPage(path: string, encodedAsJsonString: boolean, cursor: number, end: number, total: number, entries: unknown[]) {
return {
path,
kind: "container",
encodedAsJsonString,
cursor,
returned: entries.length,
total,
nextCursor: end < total ? end : null,
entries,
diagnostics: {
realtime: true,
bounded: true,
filteredForAgent: true,
policy: "仅暴露审批业务字段;配置/schema、二进制内容、内部诊断和敏感账户字段已过滤。",
maxEntriesPerPage: 50,
maxScalarCharacters: MAX_RAW_SCALAR_TEXT,
},
};
}
function rawEntry(key: string, value: unknown, path: string) {
const parsed = parseJsonContainer(value);
if (Array.isArray(parsed.value)) return { key, path, kind: "array", size: parsed.value.length, encodedAsJsonString: parsed.parsed };
if (isRecord(parsed.value)) return { key, path, kind: "object", size: Object.keys(parsed.value).length, encodedAsJsonString: parsed.parsed };
return { key, path, kind: scalarKind(parsed.value), ...scalarPreview(parsed.value) };
}
function visibleObjectEntries(record: Record<string, unknown>): Array<[string, unknown]> {
if (sensitiveLabel(fieldLabel(record) ?? "")) return [];
return Object.entries(record).filter(([key]) => shouldExposeRawKey(key, record));
}
function shouldExposeRawKey(key: string, parent: Record<string, unknown>): boolean {
if (/^(businessConfig|businessConfigDTO|submitDTO|diagnostics|identity|cookie|cookies|headers?|request|response|stack)$/i.test(key)) return false;
if (/(?:^|_)(schema|componentSchema|formSchema|templateConfig|runtimeConfig)(?:$|_)/i.test(key)) return false;
if (/fileContent|base64|thumbnail|imageData|binary|downloadToken/i.test(key)) return false;
if (/^(?:originator|actioner|approver|executor)?(?:user|dept)(?:id|ids|userid|deptid)$/i.test(key)) return false;
if (/^(?:originatorUserid|originatorDeptId|actionerUserId|approverUserId|executorUserId)$/i.test(key)) return false;
if (sensitiveLabel(key)) return false;
const label = fieldLabel(parent);
if (label && sensitiveLabel(label) && /^(value|content|displayValue|fieldValue|extValue)$/i.test(key)) return false;
return true;
}
function scalarPreview(value: unknown): Record<string, unknown> {
if (typeof value === "string") return {
value: value.slice(0, MAX_RAW_SCALAR_TEXT),
valueTruncated: value.length > MAX_RAW_SCALAR_TEXT,
totalCharacters: value.length,
};
return { value };
}
function scalarKind(value: unknown): string {
if (value === null) return "null";
return typeof value;
}
function parseJsonContainer(value: unknown): { value: unknown; parsed: boolean } {
if (typeof value !== "string") return { value, parsed: false };
const trimmed = value.trim();
if ((!trimmed.startsWith("{") && !trimmed.startsWith("[")) || trimmed.length > 2_000_000) return { value, parsed: false };
try { return { value: JSON.parse(trimmed), parsed: true }; } catch { return { value, parsed: false }; }
}
function pointerJoin(base: string, segment: string): string {
return `${base}/${segment.replace(/~/g, "~0").replace(/\//g, "~1")}`;
}
function pointerDecode(segment: string): string { return segment.replace(/~1/g, "/").replace(/~0/g, "~"); }
function isHeavyBinaryField(key: string, label: string): boolean { return /attachment|fileContent|base64|thumbnail|imageData|附件内容|影像/i.test(`${key} ${label}`); }
function heavyValue(label: string, value: unknown): boolean { return /附件|影像|发票文件|电子发票/i.test(label) || (typeof value === "string" && value.length > 20_000); }
function textValue(value: unknown): string | undefined {
const displayed = compactDisplayValue(value);
if (displayed === undefined) return undefined;
return String(displayed).trim() || undefined;
}
function numberValue(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
const parsedValue = parseJsonContainer(value).value;
if (isRecord(parsedValue)) {
for (const key of ["reimAmt", "amount", "originalCurrencyReimAmt", "expenseTotalAmount", "value"]) {
const nested = numberValue(parsedValue[key]);
if (nested !== undefined) return nested;
}
}
const text = textValue(value)?.replace(/,/g, "");
const matched = text?.match(/-?\d+(?:\.\d+)?/);
const parsed = matched ? Number(matched[0]) : NaN;
return Number.isFinite(parsed) ? parsed : undefined;
}
function dateOnly(value: unknown): string | undefined {
const text = textValue(value);
if (!text) return undefined;
const direct = text.match(/(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})/);
if (direct) return `${direct[1]}-${direct[2]!.padStart(2, "0")}-${direct[3]!.padStart(2, "0")}`;
if (/^\d{10,13}$/.test(text)) {
const timestamp = Number(text) * (text.length === 10 ? 1000 : 1);
const date = new Date(timestamp);
if (!Number.isNaN(date.getTime())) return new Date(date.getTime() + 8 * 3_600_000).toISOString().slice(0, 10);
}
return undefined;
}
function dateList(value: unknown): string[] {
const parsed = parseJsonContainer(value).value;
const values = Array.isArray(parsed) ? parsed : [parsed];
return [...new Set(values.flatMap((item) => {
const text = textValue(item) ?? "";
const matches = [...text.matchAll(/(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})/g)];
return matches.map((match) => `${match[1]}-${match[2]!.padStart(2, "0")}-${match[3]!.padStart(2, "0")}`);
}))];
}
function textList(value: unknown): string[] {
const text = textValue(value);
return text ? text.split(/[、,,;;\n]/).map((item) => item.trim()).filter(Boolean).slice(0, 50) : [];
}
function currencyValue(value: unknown): string | undefined {
const parsedValue = parseJsonContainer(value).value;
if (isRecord(parsedValue)) return currencyValue(parsedValue.currencyCode ?? parsedValue.currency ?? parsedValue.cry);
const text = textValue(value)?.toUpperCase();
if (!text) return undefined;
if (/USD|美元/.test(text)) return "USD";
if (/EUR|欧元/.test(text)) return "EUR";
if (/HKD|港币/.test(text)) return "HKD";
if (/CNY|RMB|人民币/.test(text)) return "CNY";
return text.slice(0, 10);
}
+202
View File
@@ -0,0 +1,202 @@
import { objectValue } from "./json.js";
export interface ApprovalListMetadata {
applicantName?: string;
applicantUserId?: string;
applicantDepartment?: string;
submittedAt?: string;
amount?: number;
currency?: string;
summary?: string;
}
interface FormField { label: string; value: string }
export function extractApprovalListMetadata(...sources: unknown[]): ApprovalListMetadata {
const records = sources.flatMap(knownRecords);
const fields = records.flatMap(formFields);
const applicantName = firstDirectString(records, [
"originatorUserName", "originatorName", "applicantName", "creatorName", "createUserName", "submitterName",
"aplName", "wrName",
]) ?? formValue(fields, /^(申请人|发起人|报销人|提交人|员工姓名|申请人姓名)$/)
?? applicantFromTitle(firstDirectString(records, ["title", "processInstanceTitle"]));
const applicantUserId = firstDirectString(records, [
"originatorUserId", "originatorId", "applicantUserId", "creatorUserId", "createUserId", "submitterId",
]);
const applicantDepartment = firstDirectString(records, [
"originatorDeptName", "originatorDepartmentName", "applicantDepartment", "departmentName", "deptName",
"aplDepName", "billDepName",
]) ?? formValue(fields, /^(申请部门|发起部门|报销部门|所属部门|部门)$/);
const submittedAt = dateValue(firstDirect(records, [
"createTime", "startTime", "submittedAt", "applyTime", "applyTim", "createTimeGMT", "gmtCreate", "createdAt", "createDate",
]));
const directAmount = amountValue(firstDirect(records, [
"totalAmount", "applyAmount", "reimbursementAmount", "expenseAmount", "paymentAmount", "amount", "expTotalCnt",
]));
const amountField = bestAmountField(fields);
const amount = directAmount?.value ?? amountField?.value;
const currency = directAmount?.currency ?? amountField?.currency ?? (amount !== undefined ? "CNY" : undefined);
const summary = cleanSummary(firstDirectString(records, [
"reason", "applyReason", "expenseReason", "description", "summary", "remark", "billRemark",
]) ?? formValue(fields, /^(申请事由|报销事由|费用事由|用途|申请原因|原因|费用说明|说明|摘要|备注)$/));
return {
...(applicantName ? { applicantName } : {}),
...(applicantUserId ? { applicantUserId } : {}),
...(applicantDepartment ? { applicantDepartment } : {}),
...(submittedAt ? { submittedAt } : {}),
...(amount !== undefined ? { amount } : {}),
...(currency ? { currency } : {}),
...(summary ? { summary } : {}),
};
}
function knownRecords(value: unknown): Record<string, unknown>[] {
const root = objectValue(value);
if (!root) return [];
const records = [root];
for (const key of ["result", "data", "body", "formData", "originator", "applicant", "creator"]) {
const child = objectValue(root[key]);
if (child) records.push(child);
}
return records;
}
function formFields(record: Record<string, unknown>): FormField[] {
const output: FormField[] = [];
for (const key of ["forms", "formComponentValues", "formValues", "components"]) {
const list = record[key];
if (!Array.isArray(list)) continue;
for (const item of list) {
const field = objectValue(item);
if (!field) continue;
const label = stringFrom(field.name) ?? stringFrom(field.title) ?? stringFrom(field.label);
const value = displayValue(field.value ?? field.content ?? field.displayValue ?? field.extValue);
if (label && value && !sensitiveLabel(label)) output.push({ label: normalizeLabel(label), value });
}
}
const formData = objectValue(record.formData);
if (formData) {
for (const [label, raw] of Object.entries(formData)) {
const value = displayValue(raw);
if (value && !sensitiveLabel(label)) output.push({ label: normalizeLabel(label), value });
}
}
return output;
}
function firstDirect(records: Record<string, unknown>[], keys: string[]): unknown {
for (const key of keys) {
for (const record of records) {
const value = record[key];
if (value !== undefined && value !== null && value !== "") return value;
}
}
return undefined;
}
function firstDirectString(records: Record<string, unknown>[], keys: string[]): string | undefined {
return displayValue(firstDirect(records, keys));
}
function formValue(fields: FormField[], pattern: RegExp): string | undefined {
return fields.find((field) => pattern.test(field.label))?.value;
}
function bestAmountField(fields: FormField[]): { value: number; currency?: string } | undefined {
const candidates = fields.flatMap((field) => {
const parsed = amountValue(field.value);
if (!parsed || !/(金额|总额|合计|费用)/.test(field.label) || /(单价|税率|数量)/.test(field.label)) return [];
const score = /^(报销金额|申请金额|付款金额|支付金额|总金额|费用合计|合计金额)$/.test(field.label)
? 3
: /(总|合计|报销|申请|付款)/.test(field.label) ? 2 : 1;
return [{ ...parsed, score }];
});
candidates.sort((left, right) => right.score - left.score);
return candidates[0];
}
function amountValue(value: unknown): { value: number; currency?: string } | undefined {
if (typeof value === "number" && Number.isFinite(value)) return { value };
const text = displayValue(value);
if (!text) return undefined;
const match = text.replace(/,/g, "").match(/-?\d+(?:\.\d+)?/);
if (!match) return undefined;
const parsed = Number(match[0]);
if (!Number.isFinite(parsed)) return undefined;
const currency = /(?:USD|美元|\$)/i.test(text) ? "USD"
: /(?:EUR|欧元|€)/i.test(text) ? "EUR"
: /(?:HKD|港币)/i.test(text) ? "HKD"
: /(?:CNY|RMB|人民币|[¥¥])/i.test(text) ? "CNY" : undefined;
return { value: parsed, ...(currency ? { currency } : {}) };
}
function dateValue(value: unknown): string | undefined {
let milliseconds: number | undefined;
if (typeof value === "number" && Number.isFinite(value)) milliseconds = value < 10_000_000_000 ? value * 1000 : value;
if (typeof value === "string" && value.trim()) {
const trimmed = value.trim();
if (/^\d{10,13}$/.test(trimmed)) {
const numeric = Number(trimmed);
milliseconds = trimmed.length === 10 ? numeric * 1000 : numeric;
} else {
const parsed = Date.parse(trimmed);
if (Number.isFinite(parsed)) milliseconds = parsed;
else return trimmed.slice(0, 100);
}
}
if (milliseconds === undefined) return undefined;
const date = new Date(milliseconds);
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}
function displayValue(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return undefined;
if ((trimmed.startsWith("{") || trimmed.startsWith("[")) && trimmed.length < 10_000) {
try { return displayValue(JSON.parse(trimmed)); } catch {}
}
return trimmed.slice(0, 1000);
}
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) {
const values = value.flatMap((item) => {
const displayed = displayValue(item);
return displayed ? [displayed] : [];
});
return values.length ? values.join("、").slice(0, 1000) : undefined;
}
const record = objectValue(value);
if (!record) return undefined;
for (const key of ["label", "name", "text", "value", "displayValue"]) {
const displayed = displayValue(record[key]);
if (displayed) return displayed;
}
return undefined;
}
function cleanSummary(value: string | undefined): string | undefined {
const cleaned = value?.replace(/\s+/g, " ").trim();
return cleaned ? cleaned.slice(0, 500) : undefined;
}
function applicantFromTitle(value: string | undefined): string | undefined {
const match = value?.match(/^(.{1,30}?)(?:提交的|发起的)/);
return match?.[1]?.trim() || undefined;
}
export function normalizeLabel(value: string): string {
return value.replace(/[::\s]/g, "").trim();
}
/** Shared vocabulary for sensitive field names/labels across metadata, detail and redaction layers. */
export function sensitiveLabel(value: string): boolean {
if (/招待地址|消费地址|商户地址|酒店地址|办公地址|出差地址|活动地址|费用地址|entertainmentAddress|merchantAddress|hotelAddress|businessAddress|expenseAddress/i.test(value)) {
return false;
}
return /bank.*(account|card|no|name)|account.*(number|no)|cardNo|taxNo|idcard|identity.*(number|no)|mobile|phone|address|recipientAccount|paymentAccount|银行卡|银行账号|卡号|身份证|手机号|账户号码|收款账户|付款账户|税号|地址|住址/i.test(value);
}
function stringFrom(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
+676
View File
@@ -0,0 +1,676 @@
import type { AppConfig } from "./config.js";
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js";
import type {
ApprovalAction,
ApprovalInstance,
ApprovalSource,
DingTalkClient,
OfficialTodoWorkRecord,
OfficialSubmissionRequest,
YidaApplication,
YidaTask,
} from "./dingtalk.js";
import { ApprovalSafetyError, DingTalkApiError, errorMessage } from "./errors.js";
import { extractApprovalListMetadata } from "./approval-metadata.js";
import { isRecord } from "./json.js";
import type { ApprovalHistoryRange, ApprovalHistoryScope } from "./xft.js";
export interface TodoItem {
source: ApprovalSource;
processInstanceId: string;
taskId: string;
title?: string;
processCode?: string;
clientUrl?: string;
processName?: string;
appType?: string;
applicantName?: string;
applicantUserId?: string;
applicantDepartment?: string;
submittedAt?: string;
amount?: number;
currency?: string;
summary?: string;
enrichmentStatus?: "complete" | "failed";
raw: unknown;
}
export interface ProcessApprovalItem {
source: ApprovalSource;
processInstanceId: string;
taskId: string;
appType?: string;
}
export interface ApprovalHistoryItem extends Omit<TodoItem, "taskId"> {
historyScope: ApprovalHistoryScope;
taskId?: string;
status?: string;
result?: string;
completedAt?: string;
}
export class ApprovalService {
constructor(private readonly client: DingTalkClient, private readonly config: AppConfig) {}
async listMyTodos(userId: string, source: ApprovalSource | "all", limit: number) {
const [official, yida] = await Promise.all([
source === "yida" ? Promise.resolve([]) : this.listOfficialTodos(userId, limit),
source === "official_oa" || !this.config.yida ? Promise.resolve([]) : this.listYidaTodos(userId, limit),
]);
return {
items: [...official, ...yida].slice(0, limit),
diagnostics: {
officialOaCount: official.length,
yidaCount: yida.length,
officialEnrichmentFailures: official.filter((item) => item.enrichmentStatus === "failed").length,
officialListingMode: "direct_todo_work_records",
realtime: true,
persistedBusinessData: false,
},
};
}
async listMyHistory(userId: string, scope: ApprovalHistoryScope, limit: number, range: ApprovalHistoryRange) {
const history = scope === "handled"
? await this.listOfficialHandled(userId, limit, range)
: await this.listOfficialSubmitted(userId, limit, range);
return {
items: history.items,
diagnostics: {
officialOaCount: history.items.length,
yidaHistorySupported: false,
listingMode: scope === "handled" ? "completed_work_records" : "initiated_instances_by_visible_template",
...(history.truncated ? { truncated: true, scanLimit: history.scanLimit } : {}),
realtime: true,
persistedBusinessData: false,
},
};
}
async listSubmissionTemplates(limit: number, search = "", allowedCategories: readonly string[] = []) {
const query = search.trim().toLowerCase();
return (await this.client.listOfficialTemplates())
.filter((item) => allowedCategories.length === 0 || allowedCategories.includes(item.dirName ?? ""))
.filter((item) => !query || JSON.stringify(item).toLowerCase().includes(query))
.slice(0, limit);
}
async getSubmissionSchema(processCode: string) {
const template = (await this.client.listOfficialTemplates()).find((item) => item.processCode === processCode);
if (!template) throw new ApprovalSafetyError("钉钉审批模板不可见、已停用或发生变化,请重新列出可发起模板");
const schema = await this.client.getOfficialTemplateSchema(processCode);
return { template, schema };
}
async resolveSubmissionDepartment(userId: string, requestedDeptId = -1): Promise<number> {
const departmentIds = await this.client.getUserDepartmentIds(userId);
if (departmentIds.length === 0) {
throw new ApprovalSafetyError("无法从钉钉通讯录解析当前登录用户的所属部门,请确认应用已开通通讯录个人信息读取权限");
}
const id = requestedDeptId === -1 ? departmentIds[0]! : requestedDeptId;
if (!departmentIds.includes(id)) {
throw new ApprovalSafetyError("所选发起部门不属于当前登录用户,禁止提交");
}
return id;
}
async forecastSubmission(userId: string, request: Omit<OfficialSubmissionRequest, "originatorUserId">) {
await this.getSubmissionSchema(request.processCode);
return this.client.forecastOfficialProcess({ ...request, originatorUserId: userId });
}
async submitApproval(userId: string, request: Omit<OfficialSubmissionRequest, "originatorUserId">) {
await this.getSubmissionSchema(request.processCode);
return this.client.createOfficialInstance({ ...request, originatorUserId: userId });
}
async revokeSubmission(userId: string, processInstanceId: string, remark = "") {
const instance = await this.client.getOfficialInstance(processInstanceId);
this.assertSubmissionOwner(instance, userId);
if (!isRevocableInstance(instance)) throw new ApprovalSafetyError("该钉钉审批已不在运行中,不能撤销");
try {
await this.client.terminateOfficialInstance({
processInstanceId,
operatingUserId: userId,
...(remark ? { remark } : {}),
});
} catch (error) {
if (error instanceof DingTalkApiError && (error.status === 0 || error.status >= 500)) {
return {
status: "outcome_uncertain" as const,
processInstanceId,
message: "撤销请求可能已经到达钉钉,但响应无法确认;禁止自动重试,请稍后查询客户端状态",
};
}
throw error;
}
for (let attempt = 0; attempt < 4; attempt += 1) {
const updated = await this.client.getOfficialInstance(processInstanceId);
this.assertSubmissionOwner(updated, userId);
if (isConfirmedRevokedInstance(updated)) {
return { status: "revoked" as const, processInstanceId, upstreamStatus: instanceStatus(updated) };
}
if (!isRevocableInstance(updated)) {
return {
status: "failed" as const,
processInstanceId,
upstreamStatus: instanceStatus(updated),
message: "钉钉实例已进入其他终态,未确认撤销成功;禁止自动重试",
};
}
if (attempt < 3) await new Promise<void>((resolve) => setTimeout(resolve, 500));
}
return {
status: "outcome_uncertain" as const,
processInstanceId,
message: "钉钉已接受撤销请求,但实例仍显示运行中;禁止自动重试,请稍后查询客户端状态",
};
}
async getDetail(userId: string, item: Omit<ProcessApprovalItem, "taskId">) {
if (item.source === "official_oa") {
const instance = await this.client.getOfficialInstance(item.processInstanceId);
if (this.currentOfficialPendingTasks(instance, userId).length === 0) {
throw new ApprovalSafetyError("该实例当前没有属于登录用户的待审批任务");
}
return instance;
}
if (!item.appType) throw new ApprovalSafetyError("查询宜搭详情必须提供 appType");
const pending = await this.findCurrentYidaTasks(userId, new Set([yidaTaskKey(item.appType, item.processInstanceId)]));
if (pending.size === 0) throw new ApprovalSafetyError("该宜搭实例当前没有属于登录用户的待审批任务");
const app = await this.findYidaApp(item.appType);
return this.client.getYidaInstance(item.processInstanceId, app, userId);
}
async getHistoryDetail(
userId: string,
scope: ApprovalHistoryScope,
processInstanceId: string,
taskId?: string,
) {
const instance = await this.client.getOfficialInstance(processInstanceId);
if (scope === "submitted") {
this.assertSubmissionOwner(instance, userId);
return instance;
}
const handled = (instance.tasks ?? []).some((task) => task.userId === userId
&& isOfficialHandledStatus(task.status)
&& (taskId === undefined || String(task.taskId) === taskId));
if (!handled) throw new ApprovalSafetyError("该钉钉审批没有当前登录用户已处理完成的任务");
return instance;
}
async assertOfficialTaskPending(userId: string, processInstanceId: string, taskId: string): Promise<void> {
const instance = await this.client.getOfficialInstance(processInstanceId);
this.assertOfficialTask(instance, userId, taskId);
}
private assertSubmissionOwner(instance: ApprovalInstance, userId: string): void {
const originator = firstString(instance, ["originatorUserId", "originatorUserid", "originatorId", "creatorUserId"]);
if (!originator || originator !== userId) {
throw new ApprovalSafetyError("该钉钉审批不是当前登录用户本人发起");
}
}
async isOfficialTaskPending(userId: string, processInstanceId: string, taskId: string): Promise<boolean> {
const instance = await this.client.getOfficialInstance(processInstanceId);
return this.hasOfficialPendingTask(instance, userId, taskId);
}
async processBatch(userId: string, action: ApprovalAction, remark: string, items: ProcessApprovalItem[]) {
if (items.length === 0) throw new ApprovalSafetyError("审批列表不能为空");
if (items.length > APPROVAL_BATCH_LIMIT) {
throw new ApprovalSafetyError(`一次最多处理 ${APPROVAL_BATCH_LIMIT} 张审批单`);
}
const official = items.filter((item) => item.source === "official_oa");
const yida = items.filter((item) => item.source === "yida");
const results: Array<Record<string, unknown>> = [];
for (const item of official) {
let instance: ApprovalInstance;
try {
instance = await this.client.getOfficialInstance(item.processInstanceId);
this.assertOfficialTask(instance, userId, item.taskId);
} catch (error) {
results.push({
...item,
status: "failed",
success: false,
failureConfirmed: true,
error: errorMessage(error),
});
continue;
}
let response: unknown;
try {
response = await this.client.executeOfficialApproval({
processInstanceId: item.processInstanceId,
taskId: item.taskId,
actionerUserId: userId,
result: action,
...(remark ? { remark } : {}),
});
} catch (error) {
if (isUncertainDingTalkWriteError(error)) {
results.push({
...item,
status: "outcome_uncertain",
success: false,
outcomeUncertain: true,
message: "审批写请求可能已经到达钉钉,但响应无法确认;禁止自动重试,请稍后重新查询待办",
error: "审批写请求可能已经到达钉钉,但响应无法确认;禁止自动重试,请稍后重新查询待办",
});
} else {
results.push({
...item,
status: "failed",
success: false,
failureConfirmed: true,
error: errorMessage(error),
...(error instanceof DingTalkApiError && error.requestId ? { supportReference: error.requestId } : {}),
});
}
continue;
}
let updated: ApprovalInstance;
try {
updated = await this.client.getOfficialInstance(item.processInstanceId);
} catch (error) {
results.push({
...item,
status: "outcome_uncertain",
success: false,
outcomeUncertain: true,
message: `钉钉已接收审批操作,但实时状态复核失败:${errorMessage(error)};禁止自动重试`,
error: `钉钉已接收审批操作,但实时状态复核失败:${errorMessage(error)};禁止自动重试`,
});
continue;
}
if (this.hasOfficialPendingTask(updated, userId, item.taskId)) {
const message = "钉钉已接收提交,但原任务仍在当前用户实时待办中;禁止自动重试,请稍后重新查询待办";
results.push({
...item,
status: "outcome_uncertain",
success: false,
outcomeUncertain: true,
message,
error: message,
});
continue;
}
const followUp = this.currentOfficialPendingTasks(updated, userId)[0];
results.push({
...item,
status: "succeeded",
success: true,
completed: followUp === undefined,
...(followUp ? {
followUpRequired: true,
nextTaskId: String(followUp.taskId),
message: "当前审批节点已处理,但同一审批实例生成了仍需当前用户处理的新节点",
} : {}),
response,
});
}
if (yida.length > 0) {
const wanted = new Set(yida.map((item) => yidaTaskKey(item.appType ?? "", item.processInstanceId, item.taskId)));
const currentTasks = await this.findCurrentYidaTasks(userId, wanted);
const validYida = yida.filter((item) => {
const valid = currentTasks.has(yidaTaskKey(item.appType ?? "", item.processInstanceId, item.taskId));
if (!valid) results.push({ ...item, success: false, error: "任务不属于当前登录用户、不是待办状态或已经被处理" });
return valid;
});
if (validYida.length === 0) {
return batchResult(action, items.length, results);
}
const apps = await this.client.listYidaApplications();
const appMap = new Map(apps.map((app) => [app.appType, app]));
const groups = new Map<string, ProcessApprovalItem[]>();
for (const item of validYida) {
const key = item.appType ?? "";
const group = groups.get(key) ?? [];
group.push(item);
groups.set(key, group);
}
for (const [appType, group] of groups) {
const app = appMap.get(appType);
if (!app) {
for (const item of group) results.push({ ...item, success: false, error: `找不到宜搭应用 ${appType} 或无管理权限` });
continue;
}
try {
const response = await this.client.executeYidaBatch(
app,
userId,
action,
remark,
group.map((item) => ({ taskId: item.taskId, formInstId: item.processInstanceId })),
);
for (const item of group) results.push({ ...item, success: true, response });
} catch (error) {
for (const item of group) results.push({ ...item, success: false, error: errorMessage(error) });
}
}
}
return batchResult(action, items.length, results);
}
private async listOfficialTodos(userId: string, limit: number): Promise<TodoItem[]> {
const found: TodoItem[] = [];
const seen = new Set<string>();
let offset = 0;
while (found.length < limit) {
const pageSize = Math.min(20, limit - found.length);
const page = await this.client.listOfficialTodoWorkRecords(userId, offset, pageSize);
for (const record of page.list) {
const item = officialWorkRecord(record);
if (item) {
const key = `${item.processInstanceId}\u0000${item.taskId}`;
if (!seen.has(key)) {
seen.add(key);
found.push(item);
}
}
}
offset += page.list.length;
if (!page.hasMore || page.list.length < pageSize || page.list.length === 0) break;
}
await mapConcurrent(found, this.config.officialScanConcurrency, async (item) => {
try {
const instance = await this.client.getOfficialInstance(item.processInstanceId);
Object.assign(item, extractApprovalListMetadata(instance, item.raw));
if (!item.title && instance.title) item.title = instance.title;
if (!item.processCode && typeof instance.processCode === "string") item.processCode = instance.processCode;
item.enrichmentStatus = "complete";
} catch {
item.enrichmentStatus = "failed";
}
});
return found;
}
private async listOfficialHandled(userId: string, limit: number, range: ApprovalHistoryRange) {
const found: ApprovalHistoryItem[] = [];
const seen = new Set<string>();
const scanCap = Math.min(500, Math.max(limit * 3, 100));
let offset = 0;
let hasMore = false;
while (found.length < scanCap) {
const pageSize = Math.min(20, scanCap - found.length);
const page = await this.client.listOfficialTodoWorkRecords(userId, offset, pageSize, 1);
hasMore = page.hasMore;
for (const record of page.list) {
const item = officialWorkRecord(record);
if (!item || seen.has(item.processInstanceId)) continue;
seen.add(item.processInstanceId);
found.push({ ...item, historyScope: "handled" });
}
offset += page.list.length;
if (!page.hasMore || page.list.length < pageSize || page.list.length === 0) break;
}
await this.enrichOfficialHistory(found);
const matched = found.filter((item) => historyInRange(item, "handled", range));
return {
items: sortHistory(matched).slice(0, limit),
truncated: hasMore || matched.length > limit,
scanLimit: scanCap,
};
}
private async listOfficialSubmitted(userId: string, limit: number, range: ApprovalHistoryRange) {
const templates = await this.client.listOfficialTemplates();
const ids = new Map<string, string>();
const scanCap = Math.min(500, Math.max(limit * 3, 100));
let truncated = false;
await mapConcurrent(templates, this.config.officialScanConcurrency, async (template) => {
if (!template.processCode) return;
if (ids.size >= scanCap) { truncated = true; return; }
let cursor = 0;
while (ids.size < scanCap) {
const page = await this.client.listOfficialInitiatedInstanceIds(
template.processCode,
userId,
range.startTime,
range.endTime,
cursor,
Math.min(20, scanCap - ids.size),
);
for (const id of page.instanceIds) {
if (ids.size >= scanCap) { truncated = true; break; }
ids.set(id, template.processCode);
}
if (page.nextCursor === undefined || page.instanceIds.length === 0) break;
if (ids.size >= scanCap) { truncated = true; break; }
cursor = page.nextCursor;
}
});
const found: ApprovalHistoryItem[] = [...ids].map(([processInstanceId, processCode]) => ({
source: "official_oa",
historyScope: "submitted",
processInstanceId,
processCode,
raw: { processInstanceId, processCode },
}));
await this.enrichOfficialHistory(found);
const matched = found.filter((item) => historyInRange(item, "submitted", range));
return {
items: sortHistory(matched).slice(0, limit),
truncated: truncated || matched.length > limit,
scanLimit: scanCap,
};
}
private async enrichOfficialHistory(items: ApprovalHistoryItem[]): Promise<void> {
await mapConcurrent(items, this.config.officialScanConcurrency, async (item) => {
try {
const instance = await this.client.getOfficialInstance(item.processInstanceId);
Object.assign(item, extractApprovalListMetadata(instance, item.raw));
if (!item.title && instance.title) item.title = instance.title;
if (!item.processCode && typeof instance.processCode === "string") item.processCode = instance.processCode;
item.status = firstString(instance, ["status", "processStatus"]);
item.result = firstString(instance, ["result", "processResult"]);
item.completedAt = historyCompletionTime(instance, item.taskId);
item.raw = instance;
item.enrichmentStatus = "complete";
} catch {
item.enrichmentStatus = "failed";
}
});
}
private async listYidaTodos(userId: string, limit: number): Promise<TodoItem[]> {
const output: TodoItem[] = [];
for (let page = 1; page <= 100 && output.length < limit; page += 1) {
const data = await this.client.listYidaCorpTasks(userId, page, Math.min(100, limit));
const list = yidaTaskPage(data);
for (const value of list) {
const task = value as YidaTask;
const status = task.taskStatus ?? task.status;
const processInstanceId = task.processInstanceId ?? task.formInstId;
if (!isPendingYidaStatus(status) || !processInstanceId || !task.taskId || !task.appType) continue;
output.push({
source: "yida",
processInstanceId,
taskId: task.taskId,
appType: task.appType,
...(task.title || task.processInstanceTitle ? { title: task.title ?? task.processInstanceTitle } : {}),
...extractApprovalListMetadata(task),
raw: task,
});
if (output.length >= limit) break;
}
if (list.length < Math.min(100, limit)) break;
}
return output;
}
private async findCurrentYidaTasks(userId: string, wanted: Set<string>): Promise<Set<string>> {
const found = new Set<string>();
for (let page = 1; page <= 100 && found.size < wanted.size; page += 1) {
const list = yidaTaskPage(await this.client.listYidaCorpTasks(userId, page, 100));
for (const value of list) {
const task = value as YidaTask;
const processInstanceId = task.processInstanceId ?? task.formInstId;
const status = task.taskStatus ?? task.status;
if (!task.appType || !processInstanceId || !isPendingYidaStatus(status)) continue;
const instanceKey = yidaTaskKey(task.appType, processInstanceId);
const exactKey = task.taskId ? yidaTaskKey(task.appType, processInstanceId, task.taskId) : "";
if (wanted.has(instanceKey)) found.add(instanceKey);
if (exactKey && wanted.has(exactKey)) found.add(exactKey);
}
if (list.length < 100) break;
}
return found;
}
private assertOfficialTask(instance: ApprovalInstance, userId: string, taskId: string): void {
if (instance.status && instance.status !== "RUNNING") throw new ApprovalSafetyError(`审批实例状态为 ${instance.status},不能处理`);
const found = this.hasOfficialPendingTask(instance, userId, taskId);
if (!found) throw new ApprovalSafetyError("任务不属于当前登录用户、不是待审批状态或已经被处理");
}
private hasOfficialPendingTask(instance: ApprovalInstance, userId: string, taskId: string): boolean {
return this.currentOfficialPendingTasks(instance, userId).some((task) => String(task.taskId) === taskId);
}
private currentOfficialPendingTasks(instance: ApprovalInstance, userId: string) {
if (instance.status && instance.status !== "RUNNING") return [];
return (instance.tasks ?? []).filter(
(task) => task.userId === userId && isOfficialPendingStatus(task.status) && task.taskId !== undefined,
);
}
private async findYidaApp(appType: string): Promise<YidaApplication> {
const app = (await this.client.listYidaApplications()).find((item) => item.appType === appType);
if (!app) throw new ApprovalSafetyError(`找不到宜搭应用 ${appType} 或管理员无权访问`);
return app;
}
}
function isUncertainDingTalkWriteError(error: unknown): boolean {
return !(error instanceof DingTalkApiError) || error.status === 0 || error.status >= 500;
}
function instanceStatus(instance: ApprovalInstance): string | undefined {
return firstString(instance, ["status", "processStatus", "result"]);
}
function isRevocableInstance(instance: ApprovalInstance): boolean {
return ["RUNNING", "NEW", "PENDING"].includes((instanceStatus(instance) ?? "").toUpperCase());
}
function isConfirmedRevokedInstance(instance: ApprovalInstance): boolean {
return ["TERMINATED", "WITHDRAWN", "CANCELED", "CANCELLED", "DELETED"].includes((instanceStatus(instance) ?? "").toUpperCase());
}
function firstString(value: Record<string, unknown>, keys: string[]): string | undefined {
for (const key of keys) {
const item = value[key];
if (typeof item === "string" && item.trim()) return item.trim();
}
return undefined;
}
async function mapConcurrent<T>(items: T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> {
let cursor = 0;
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (cursor < items.length) {
const index = cursor++;
await worker(items[index]!);
}
}));
}
function yidaTaskPage(data: Record<string, unknown>): unknown[] {
if (Array.isArray(data.data)) return data.data;
const result = data.result;
if (Array.isArray(result)) return result;
if (isRecord(result) && Array.isArray(result.data)) return result.data;
return [];
}
function isPendingYidaStatus(status: string | undefined): boolean {
return status !== undefined && ["running", "new", "todo", "pending"].includes(status.toLowerCase());
}
function isOfficialPendingStatus(status: string | undefined): boolean {
return status !== undefined && ["new", "running"].includes(status.toLowerCase());
}
function isOfficialHandledStatus(status: string | undefined): boolean {
return status !== undefined && ["completed", "finished", "done", "executed"].includes(status.toLowerCase());
}
function historyCompletionTime(instance: ApprovalInstance, taskId?: string): string | undefined {
const task = taskId === undefined ? undefined : (instance.tasks ?? []).find((item) => String(item.taskId) === taskId);
const value = task?.finishTime ?? task?.completedAt ?? task?.endTime ?? task?.operateTime ?? task?.operationTime
?? task?.modifiedTime ?? task?.updateTime
?? instance.finishTime ?? instance.completedAt ?? instance.endTime;
if (typeof value === "string" && value.trim()) {
const numeric = Number(value);
if (/^\d+(?:\.\d+)?$/.test(value.trim()) && Number.isFinite(numeric)) return epochIso(numeric);
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : value.trim();
}
if (typeof value === "number" && Number.isFinite(value)) return epochIso(value);
return undefined;
}
function sortHistory<T extends ApprovalHistoryItem>(items: T[]): T[] {
return items.sort((left, right) => historyTime(right) - historyTime(left));
}
function historyTime(item: ApprovalHistoryItem): number {
const parsed = Date.parse(item.completedAt ?? item.submittedAt ?? "");
return Number.isFinite(parsed) ? parsed : 0;
}
function historyInRange(item: ApprovalHistoryItem, scope: ApprovalHistoryScope, range: ApprovalHistoryRange): boolean {
const raw = scope === "submitted" ? item.submittedAt : item.completedAt ?? item.submittedAt;
const timestamp = Date.parse(raw ?? "");
return Number.isFinite(timestamp) && timestamp >= range.startTime && timestamp <= range.endTime;
}
function epochIso(value: number): string {
return new Date(value < 10_000_000_000 ? value * 1000 : value).toISOString();
}
function yidaTaskKey(appType: string, processInstanceId: string, taskId?: string): string {
return `${appType}\u0000${processInstanceId}${taskId === undefined ? "" : `\u0000${taskId}`}`;
}
function batchResult(action: ApprovalAction, total: number, results: Array<Record<string, unknown>>) {
return {
action,
total,
succeeded: results.filter((value) => value.success).length,
failed: results.filter((value) => !value.success).length,
results,
};
}
function officialWorkRecord(record: OfficialTodoWorkRecord): TodoItem | undefined {
if (!record.instance_id || record.task_id === undefined) return undefined;
const clientUrl = safeDingTalkClientUrl(record.url);
return {
source: "official_oa",
processInstanceId: record.instance_id,
taskId: String(record.task_id),
...(record.title ? { title: record.title } : {}),
...(clientUrl ? { clientUrl } : {}),
...extractApprovalListMetadata(record),
raw: record,
};
}
function safeDingTalkClientUrl(value: string | undefined): string | undefined {
if (!value) return undefined;
try {
const parsed = new URL(value);
return parsed.protocol === "https:" && parsed.hostname === "aflow.dingtalk.com" ? parsed.toString() : undefined;
} catch { return undefined; }
}
+505
View File
@@ -0,0 +1,505 @@
#!/usr/bin/env node
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js";
import { isDirectEntrypoint } from "./entrypoint.js";
interface BridgeState {
client?: { client_id: string; client_secret?: string };
tokens?: { access_token: string; refresh_token?: string; expires_in?: number; savedAt: number };
device?: {
device_code: string;
user_code: string;
verification_uri_complete: string;
expiresAt: number;
interval: number;
};
onboarding?: {
schemaVersion: 1;
status: "pending" | "delivered";
deliveredAt?: string;
};
}
export const APPROVAL_ONBOARDING_EXAMPLES = [
"帮我列出当前待处理的审批",
"帮我查看我处理过或我发起的审批",
"总结费用类待办,按金额和费用发生日期整理",
"列出我可以发起的审批模板",
"先列出待办,我确认后同意指定的几张",
] as const;
class AuthRequired extends Error {
constructor(readonly challenge: NonNullable<BridgeState["device"]>) { super("需要钉钉登录"); }
}
export class RemoteBridge {
private state: BridgeState;
private client?: Client;
private connecting?: Promise<Client>;
private activeCalls = 0;
private readonly callWaiters: Array<() => void> = [];
readonly endpoint: URL;
readonly origin: URL;
constructor(endpoint: string, private readonly statePath: string, private readonly fetchFn: typeof fetch = fetch) {
this.endpoint = normalizeEndpoint(endpoint);
this.origin = new URL(this.endpoint.origin);
this.state = this.loadState();
}
ensureOnboardingPending(): void {
const diskState = this.loadState();
this.state = diskState;
if (this.state.onboarding) return;
this.state.onboarding = { schemaVersion: 1, status: "pending" };
this.saveState();
}
async loginChallenge() {
await this.tryCompleteDeviceLogin();
if (!this.usableAccessToken()) await this.refreshAccessToken();
if (this.usableAccessToken()) {
const onboarding = this.takeOnboarding();
return {
authenticated: true,
message: onboarding ? `已登录,可以读取审批。\n\n${onboardingMessage()}` : "已登录,可以读取审批。",
...(onboarding ? { onboarding } : {}),
};
}
const challenge = await this.startDeviceLogin();
return this.presentChallenge(challenge);
}
async call(name: string, args: Record<string, unknown>): Promise<any> {
return this.withCallSlot(async () => {
try {
const client = await this.remoteClient();
return this.appendOnboarding(await client.callTool({ name, arguments: args }));
} catch (error) {
if (error instanceof AuthRequired) return this.authResult(error.challenge);
throw error;
}
});
}
private async withCallSlot<T>(work: () => Promise<T>): Promise<T> {
if (this.activeCalls < 3) this.activeCalls += 1;
else await new Promise<void>((resolve) => this.callWaiters.push(resolve));
try { return await work(); }
finally {
const next = this.callWaiters.shift();
if (next) next();
else this.activeCalls -= 1;
}
}
private async remoteClient(): Promise<Client> {
if (this.client && this.usableAccessToken()) return this.client;
if (this.client) {
const stale = this.client;
this.client = undefined;
try { await stale.close(); } catch { /* stale sessions are best-effort cleanup */ }
}
if (this.connecting) return this.connecting;
this.connecting = this.connectRemote();
try { return await this.connecting; } finally { this.connecting = undefined; }
}
private async connectRemote(allowRefreshRetry = true): Promise<Client> {
await this.tryCompleteDeviceLogin();
if (!this.usableAccessToken()) await this.refreshAccessToken();
if (!this.usableAccessToken()) throw new AuthRequired(await this.startDeviceLogin());
const client = new Client({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.8" });
const transport = new StreamableHTTPClientTransport(this.endpoint, {
requestInit: { headers: { authorization: `Bearer ${this.state.tokens!.access_token}` } },
});
try { await client.connect(transport); }
catch (error) {
// A gateway restart, reverse-proxy reload or short network outage must
// not destroy an otherwise refreshable login session.
if (!isAuthenticationFailure(error)) {
throw new Error("审批网关暂时无法连接,已保留登录状态,请稍后重试", { cause: error });
}
const refreshToken = this.state.tokens?.refresh_token;
if (refreshToken && allowRefreshRetry) {
this.state.tokens = { access_token: "", refresh_token: refreshToken, expires_in: 0, savedAt: 0 };
this.saveState();
await this.refreshAccessToken();
if (this.usableAccessToken()) return this.connectRemote(false);
}
this.state.tokens = undefined;
this.saveState();
throw new AuthRequired(await this.startDeviceLogin());
}
this.client = client;
return client;
}
private usableAccessToken(): boolean {
const token = this.state.tokens;
return Boolean(token?.access_token && token.savedAt + (token.expires_in ?? 900) * 1000 > Date.now() + 30_000);
}
private async ensureClient(): Promise<NonNullable<BridgeState["client"]>> {
if (this.state.client?.client_id) return this.state.client;
const response = await this.fetchFn(new URL("/register", this.origin), {
method: "POST", headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
client_name: "DingTalk Approval stdio bridge",
redirect_uris: ["https://localhost.invalid/dingtalk-approval-device"],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
}),
});
const value = await response.json() as { client_id?: string; client_secret?: string };
if (!response.ok || !value.client_id) throw new Error("远程 MCP 拒绝注册兼容桥客户端");
this.state.client = { client_id: value.client_id, ...(value.client_secret ? { client_secret: value.client_secret } : {}) };
this.saveState();
return this.state.client;
}
private async startDeviceLogin(): Promise<NonNullable<BridgeState["device"]>> {
if (this.state.device && this.state.device.expiresAt > Date.now()) return this.state.device;
const client = await this.ensureClient();
const response = await this.fetchFn(new URL("/oauth/device/authorize", this.origin), {
method: "POST", headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ client_id: client.client_id, scope: "mcp:tools", resource: this.endpoint.toString() }),
});
const value = await response.json() as Record<string, unknown>;
if (!response.ok || typeof value.device_code !== "string" || typeof value.verification_uri_complete !== "string") {
throw new Error("远程 MCP 无法创建钉钉登录链接");
}
this.state.device = {
device_code: value.device_code,
user_code: String(value.user_code ?? ""),
verification_uri_complete: value.verification_uri_complete,
expiresAt: Date.now() + Number(value.expires_in ?? 600) * 1000,
interval: Number(value.interval ?? 5),
};
this.saveState();
return this.state.device;
}
private async tryCompleteDeviceLogin(): Promise<void> {
const device = this.state.device;
const client = this.state.client;
if (!device || !client || device.expiresAt <= Date.now()) {
if (device) { this.state.device = undefined; this.saveState(); }
return;
}
const response = await this.fetchFn(new URL("/oauth/device/token", this.origin), {
method: "POST", headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ client_id: client.client_id, device_code: device.device_code }),
});
const value = await response.json() as Record<string, unknown>;
if (!response.ok) {
if (value.error === "authorization_pending" || value.error === "slow_down") return;
if (isExplicitOAuthInvalidation(response.status, value)) {
this.state.device = undefined;
this.saveState();
}
return;
}
if (typeof value.access_token !== "string") return;
this.state.tokens = {
access_token: value.access_token,
...(typeof value.refresh_token === "string" ? { refresh_token: value.refresh_token } : {}),
expires_in: Number(value.expires_in ?? 900), savedAt: Date.now(),
};
this.state.device = undefined;
this.saveState();
}
private async refreshAccessToken(): Promise<void> {
const diskState = this.loadState();
if ((diskState.tokens?.savedAt ?? 0) > (this.state.tokens?.savedAt ?? 0)) {
this.state.tokens = diskState.tokens;
if (diskState.client?.client_id) this.state.client = diskState.client;
if (this.usableAccessToken()) return;
}
const refreshToken = this.state.tokens?.refresh_token;
const client = this.state.client;
if (!refreshToken || !client) return;
const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: client.client_id });
const response = await this.fetchFn(new URL("/token", this.origin), {
method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, body,
});
const value = await response.json() as Record<string, unknown>;
if (!response.ok || typeof value.access_token !== "string") {
// Another bridge process may have rotated the single-use refresh token
// while this request was in flight. Prefer its newer saved state.
const latest = this.loadState();
if ((latest.tokens?.savedAt ?? 0) > (this.state.tokens?.savedAt ?? 0)) {
this.state.tokens = latest.tokens;
if (latest.client?.client_id) this.state.client = latest.client;
if (this.usableAccessToken()) return;
}
if (isExplicitOAuthInvalidation(response.status, value)) {
this.state.tokens = undefined;
this.saveState();
return;
}
throw new Error("审批网关暂时无法刷新登录,已保留登录状态,请稍后重试");
}
this.state.tokens = {
access_token: value.access_token,
refresh_token: typeof value.refresh_token === "string" ? value.refresh_token : refreshToken,
expires_in: Number(value.expires_in ?? 900), savedAt: Date.now(),
};
this.saveState();
}
private presentChallenge(challenge: NonNullable<BridgeState["device"]>) {
const directUrl = challenge.verification_uri_complete;
const expiresAt = new Date(challenge.expiresAt).toISOString();
const message = [
"需要钉钉授权。系统不会另行推送或弹出登录链接。",
`请直接打开:[打开钉钉授权登录](${directUrl})`,
`备用原始链接:${directUrl}`,
...(challenge.user_code ? [`授权码:${challenge.user_code}`] : []),
`链接有效期至:${expiresAt}`,
"Agent 必须在当前回复正文中原样显示以上链接,不得只说“已生成”或“已推送”。",
"登录完成后重新调用原工具。",
].join("\n");
return {
authenticated: false,
authChallenge: {
provider: "dingtalk", type: "link", directUrl,
userCode: challenge.user_code, expiresAt,
pollAfterSeconds: challenge.interval,
},
// Some stdio MCP hosts render only content.text and discard
// structuredContent. Keep the structured challenge for capable agents,
// but always put the short-lived URL in the portable text fallback.
message,
};
}
private authResult(challenge: NonNullable<BridgeState["device"]>) {
return bridgeAuthToolResult(this.presentChallenge(challenge));
}
private takeOnboarding() {
const diskState = this.loadState();
if (diskState.onboarding?.status === "delivered") {
this.state.onboarding = diskState.onboarding;
return undefined;
}
if (diskState.onboarding?.status === "pending") this.state.onboarding = diskState.onboarding;
if (this.state.onboarding?.status !== "pending") return undefined;
const onboarding = {
firstRun: true as const,
examples: [...APPROVAL_ONBOARDING_EXAMPLES],
writeSafety: "审批写操作只会在用户明确指定动作和目标后执行。",
};
this.state.onboarding = { schemaVersion: 1, status: "delivered", deliveredAt: new Date().toISOString() };
this.saveState();
return onboarding;
}
private appendOnboarding(result: any): any {
if (result?.isError === true) return result;
const onboarding = this.takeOnboarding();
if (!onboarding) return result;
const record = result && typeof result === "object" && !Array.isArray(result) ? result : {};
const content = Array.isArray(record.content) ? [...record.content] : [];
content.push({ type: "text", text: onboardingMessage() });
const structured = record.structuredContent && typeof record.structuredContent === "object" && !Array.isArray(record.structuredContent)
? record.structuredContent : {};
return { ...record, content, structuredContent: { ...structured, onboarding } };
}
private loadState(): BridgeState {
if (!existsSync(this.statePath)) return {};
try { return JSON.parse(readFileSync(this.statePath, "utf8")) as BridgeState; } catch { return {}; }
}
private saveState(): void {
mkdirSync(dirname(this.statePath), { recursive: true, mode: 0o700 });
chmodSync(dirname(this.statePath), 0o700);
const temporary = `${this.statePath}.tmp-${process.pid}`;
writeFileSync(temporary, JSON.stringify(this.state), { mode: 0o600 });
chmodSync(temporary, 0o600);
renameSync(temporary, this.statePath);
}
}
type BridgeLoginResult = Awaited<ReturnType<RemoteBridge["loginChallenge"]>>;
function onboardingMessage(): string {
return [
"首次登录已完成。你可以直接这样使用钉钉审批:",
...APPROVAL_ONBOARDING_EXAMPLES.map((example) => `- “${example}”`),
"审批写操作只会在你明确指定动作和目标后执行。",
].join("\n");
}
export function bridgeAuthToolResult(result: BridgeLoginResult) {
const content: Array<
{ type: "text"; text: string }
| { type: "resource_link"; name: string; title: string; uri: string; description: string }
> = [{ type: "text", text: result.message }];
if ("authChallenge" in result) {
content.push({
type: "resource_link",
name: "dingtalk-approval-login",
title: "打开钉钉授权登录",
uri: result.authChallenge.directUrl,
description: "当前用户的短期钉钉授权登录链接",
});
}
return { content, structuredContent: { result } };
}
function isAuthenticationFailure(error: unknown): boolean {
const record = error && typeof error === "object" ? error as Record<string, unknown> : {};
const status = Number(record.status ?? record.statusCode ?? record.code);
if (status === 401 || status === 403) return true;
const message = error instanceof Error ? error.message : String(error);
return /^(?:MCP error[^:]*:\s*)?(?:Unauthorized|Forbidden|Invalid[_ -]?token)\b/i.test(message)
|| /\bHTTP\s+(?:401|403)\b/i.test(message);
}
function isExplicitOAuthInvalidation(status: number, value: Record<string, unknown>): boolean {
const code = typeof value.error === "string" ? value.error : "";
return status >= 400 && status < 500
&& ["invalid_grant", "invalid_client", "unauthorized_client", "access_denied", "expired_token"].includes(code);
}
function normalizeEndpoint(value: string): URL {
const url = new URL(value);
if (url.protocol !== "https:" || url.pathname !== "/mcp") throw new Error("远程 MCP 必须是 HTTPS /mcp 地址");
return url;
}
function parseEndpoint(argv: string[]): string {
const index = argv.indexOf("--url");
return (index >= 0 ? argv[index + 1] : undefined) ?? process.env.DINGTALK_APPROVAL_MCP_URL ?? "";
}
export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.8" }, {
instructions: `首次使用或登录过期时,工具不会通过其他消息通道自动发送链接。必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。处理整组查询结果优先使用selectionId;同意/拒绝无需逐张查询动作,只有退回需要targetId。每次最多处理 ${APPROVAL_BATCH_LIMIT} 条;超过时可从同一次列表中分组,不能自动补偿或重试。任一引用预检失败时整批零写入。`,
});
server.registerTool("approval_login", {
title: "登录审批服务", description: "创建或检查唯一的钉钉登录链接;同一身份同时用于钉钉OA和薪福通。", inputSchema: {},
annotations: { readOnlyHint: true, destructiveHint: false },
}, async () => {
const result = await bridge.loginChallenge();
return bridgeAuthToolResult(result);
});
server.registerTool("approval_list_todos", {
title: "列出我的审批待办", description: "实时列出当前登录用户本人的钉钉OA和薪福通待办。", inputSchema: {
source: z.enum(["all", "dingtalk", "xft"]).default("all"), query: z.string().max(200).default(""),
limit: z.number().int().min(1).max(200).default(50),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_list_todos", args));
server.registerTool("approval_list_history", {
title: "列出我的审批历史", description: "实时列出当前用户处理过或本人发起的钉钉OA和薪福通审批;默认最近30天,可指定最长120天的时间窗口;结果仅用于读取。", inputSchema: {
scope: z.enum(["handled", "submitted"]),
source: z.enum(["all", "dingtalk", "xft"]).default("all"), query: z.string().max(200).default(""),
limit: z.number().int().min(1).max(200).default(50),
startDate: z.string().max(40).default("").describe("可选;YYYY-MM-DD(上海时区)或带时区 ISO 8601;默认最近30天"),
endDate: z.string().max(40).default("").describe("可选且包含结束日;单次跨度最多120天"),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_list_history", args));
server.registerTool("approval_get_detail", {
title: "获取审批详情", description: "读取待办或历史列表返回的审批引用对应的实时详情。", inputSchema: {
approvalId: z.string().min(20).max(4096), view: z.enum(["summary", "expense", "raw"]).default("summary"),
rawPath: z.string().max(1024).default(""), rawCursor: z.number().int().min(0).default(0),
rawLimit: z.number().int().min(1).max(50).default(20),
rawQuery: z.string().max(200).default("").describe("raw 视图可选的有界字段搜索"),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_get_detail", args));
server.registerTool("approval_get_actions", {
title: "获取审批可执行动作", description: "实时检查同意、拒绝和退回能力。", inputSchema: { approvalId: z.string().min(20).max(4096) },
annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_get_actions", args));
server.registerTool("approval_process_batch", {
title: "处理审批", description: `仅在用户明确授权后处理本人待办;一次最多 ${APPROVAL_BATCH_LIMIT} 条。整组使用selectionId,部分项目使用items;写前校验整批引用,任一无效则零写入,不自动重试。`, inputSchema: {
action: z.enum(["approve", "reject", "return"]), remark: z.string().max(1000).default(""),
selectionId: z.string().min(20).max(4096).optional(),
items: z.array(z.object({ approvalId: z.string().min(20).max(4096), targetId: z.string().min(20).max(4096).optional() }))
.max(APPROVAL_BATCH_LIMIT, `一次最多处理 ${APPROVAL_BATCH_LIMIT} 条审批`).default([]),
}, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
}, async (args) => bridge.call("approval_process_batch", args));
server.registerTool("approval_list_submission_templates", {
title: "列出发起审批候选模板", description: "实时列出候选模板;是否可由 API 构造需继续读取 submissionCapability。", inputSchema: {
source: z.enum(["all", "dingtalk", "xft"]).default("all"), query: z.string().max(200).default(""),
limit: z.number().int().min(1).max(100).default(50),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_list_submission_templates", args));
server.registerTool("approval_get_submission_schema", {
title: "获取发起审批表单结构", description: "实时读取模板字段、必填项、复杂字段格式和 API 构造能力。", inputSchema: {
templateId: z.string().min(20).max(4096),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_get_submission_schema", args));
server.registerTool("approval_get_submission_options", {
title: "查询发起审批可用选项", description: "实时查询当前登录用户本人的薪福通承担部门,或搜索城市编码。", inputSchema: {
templateId: z.string().min(20).max(4096), kind: z.enum(["department", "city"]),
query: z.string().max(100).default(""), limit: z.number().int().min(1).max(100).default(50),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_get_submission_options", args));
const submissionInputSchema = {
templateId: z.string().min(20).max(4096),
values: z.record(z.string().min(1).max(200), z.unknown()),
deptId: z.number().int().default(-1).describe("钉钉发起部门;默认-1,由网关按当前登录用户实时解析并验证"),
targetSelectActioners: z.array(z.object({
actionerKey: z.string().min(1).max(500),
actionerStaffIds: z.array(z.string().min(1).max(500)).min(1).max(50),
})).max(20).default([]),
allowDuplicateInvoices: z.boolean().default(false).describe("仅在网关已提示重复风险或查重不完整、且用户明确确认仍要提交后设为 true"),
};
server.registerTool("approval_prepare_submission", {
title: "校验并预览新审批", description: "无副作用校验全部字段并自动查重本人近30天钉钉/薪福通发票;有重复风险或查重不完整时要求用户明确确认,不会直接生成提交引用。", inputSchema: submissionInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_prepare_submission", args));
server.registerTool("approval_submit", {
title: "正式发起审批", description: "仅在用户看过最终预览并明确确认后,使用完全相同内容和重复风险决定发起审批;禁止自动重试。", inputSchema: {
confirmationId: z.string().min(20).max(4096), ...submissionInputSchema,
}, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
}, async (args) => bridge.call("approval_submit", args));
server.registerTool("approval_revoke_submission", {
title: "撤销本人发起的审批", description: "使用提单成功后返回的 revokeId 撤销本人发起的审批;禁止自动重试。薪福通 recall 当前为实验能力,必须以写后状态复核为准。", inputSchema: {
revokeId: z.string().min(20).max(4096), remark: z.string().max(1000).default(""),
}, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
}, async (args) => bridge.call("approval_revoke_submission", args));
server.registerTool("approval_get_submission_status", {
title: "核验本人已发起审批状态", description: "只读核验本人发起的薪福通单据状态;仍可撤回时返回新的短期revokeId。", inputSchema: {
source: z.literal("xft"), businessId: z.string().regex(/^\d{10,20}$/),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_get_submission_status", args));
return server;
}
export async function startBridge(endpoint: string, statePath = process.env.DINGTALK_APPROVAL_BRIDGE_STATE
?? join(homedir(), ".config", "dingtalk-approval-bridge", "session.json")): Promise<void> {
const bridge = new RemoteBridge(endpoint, resolve(statePath));
const server = createBridgeMcpServer(bridge);
await server.connect(new StdioServerTransport());
}
async function runPostInstallLogin(endpoint: string, statePath: string): Promise<void> {
const bridge = new RemoteBridge(endpoint, resolve(statePath));
bridge.ensureOnboardingPending();
process.stdout.write(`${JSON.stringify(await bridge.loginChallenge())}\n`);
}
if (isDirectEntrypoint(import.meta.url, process.argv[1])) {
const args = process.argv.slice(2);
const endpoint = parseEndpoint(args);
const statePath = process.env.DINGTALK_APPROVAL_BRIDGE_STATE
?? join(homedir(), ".config", "dingtalk-approval-bridge", "session.json");
const task = args.includes("--post-install-login")
? runPostInstallLogin(endpoint, statePath)
: startBridge(endpoint, statePath);
task.catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
}
+809
View File
@@ -0,0 +1,809 @@
#!/usr/bin/env node
import { createHash, createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
import {
chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync,
readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path";
import { spawn, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
export interface ReleaseArtifact {
url: string;
sha256: string;
}
export interface ClientReleaseManifest {
schemaVersion: 3;
version: string;
createdAt: string;
gatewayUrl: string;
signing: { algorithm: "Ed25519"; keyId: string };
requirements: { node: string };
artifacts: {
installer: ReleaseArtifact;
publicKey: ReleaseArtifact & { keyId: string };
client: ReleaseArtifact;
skill: ReleaseArtifact;
};
}
export interface ClientInstallState {
schemaVersion: 1;
version: string;
installedAt: string;
manifestUrl: string;
gatewayUrl: string;
clientSha256: string;
skillSha256: string;
runtimeDir: string;
skillFile: string;
}
export interface ClientPaths {
root: string;
stateFile: string;
publicKeyFile: string;
versionsDir: string;
binDir: string;
skillDir: string;
skillFile: string;
launcherFile: string;
cliCommand: string;
bridgeCommand: string;
sessionFile: string;
}
interface PostInstallResult {
required: true;
attempted: true;
status: "authenticated" | "authorization_required" | "unavailable";
authenticated: boolean;
message: string;
authChallenge?: {
directUrl: string;
userCode?: string;
expiresAt?: string;
};
onboarding?: {
firstRun: true;
examples: string[];
writeSafety?: string;
};
confirmTool: { name: "approval_login"; arguments: Record<string, never> };
verifyTool: { name: "approval_list_todos"; arguments: { source: "all"; query: ""; limit: 1 } };
completionCriteria: string;
}
interface SignatureEnvelope {
schemaVersion: 1;
algorithm: "Ed25519";
keyId: string;
signature: string;
}
interface CommandContext {
argv: string[];
env: NodeJS.ProcessEnv;
platform: NodeJS.Platform;
home: string;
fetchFn: typeof fetch;
stdout: (value: string) => void;
stderr: (value: string) => void;
}
const LEGACY_BIN_NAMES = ["dingtalk-admin", "dingtalk-approval-bridge", "dingtalk-approval-mcp", "dingtalk-mcp"];
const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/;
const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
const DOWNLOAD_TIMEOUT_MS = 30_000;
const MANIFEST_MAX_AGE_MS = 180 * 24 * 60 * 60_000;
export function clientPaths(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
home = homedir(),
): ClientPaths {
const pathApi = platform === "win32" ? win32 : posix;
const override = env.DINGTALK_APPROVAL_CLIENT_HOME?.trim();
const root = pathApi.resolve(override || (platform === "win32"
? pathApi.join(env.LOCALAPPDATA?.trim() || pathApi.join(home, "AppData", "Local"), "DingTalkApproval")
: platform === "darwin"
? pathApi.join(home, "Library", "Application Support", "dingtalk-approval")
: pathApi.join(env.XDG_DATA_HOME?.trim() || pathApi.join(home, ".local", "share"), "dingtalk-approval")));
const binDir = pathApi.join(root, "bin");
const executableSuffix = platform === "win32" ? ".cmd" : "";
return {
root,
stateFile: pathApi.join(root, "install-state.json"),
publicKeyFile: pathApi.join(root, "release-signing-public.pem"),
versionsDir: pathApi.join(root, "versions"),
binDir,
skillDir: pathApi.join(root, "skill", "dingtalk-approval"),
skillFile: pathApi.join(root, "skill", "dingtalk-approval", "SKILL.md"),
launcherFile: pathApi.join(binDir, "launcher.mjs"),
cliCommand: pathApi.join(binDir, `dingtalk-approval${executableSuffix}`),
bridgeCommand: pathApi.join(binDir, `dingtalk-approval-bridge${executableSuffix}`),
sessionFile: pathApi.join(root, "state", "session.json"),
};
}
export function releaseKeyId(publicKeyPem: string | Buffer): string {
const der = createPublicKey(publicKeyPem).export({ type: "spki", format: "der" });
return createHash("sha256").update(der).digest("hex");
}
export function verifySignedManifest(
manifestBytes: Buffer,
signatureBytes: Buffer,
publicKeyPem: string | Buffer,
manifestUrl: string,
): ClientReleaseManifest {
let envelope: SignatureEnvelope;
try { envelope = JSON.parse(signatureBytes.toString("utf8")) as SignatureEnvelope; }
catch { throw new Error("发布清单签名文件不是有效 JSON"); }
const keyId = releaseKeyId(publicKeyPem);
if (envelope.schemaVersion !== 1 || envelope.algorithm !== "Ed25519" || envelope.keyId !== keyId) {
throw new Error("发布清单签名的算法或密钥编号不受信任");
}
let signature: Buffer;
try { signature = Buffer.from(envelope.signature, "base64"); }
catch { throw new Error("发布清单签名编码无效"); }
if (signature.length === 0 || !verifySignature(null, manifestBytes, publicKeyPem, signature)) {
throw new Error("发布清单 Ed25519 签名无效");
}
let manifest: ClientReleaseManifest;
try { manifest = JSON.parse(manifestBytes.toString("utf8")) as ClientReleaseManifest; }
catch { throw new Error("发布清单不是有效 JSON"); }
validateManifest(manifest, manifestUrl, keyId);
return manifest;
}
export async function runClientInstaller(
argv = process.argv.slice(2),
overrides: Partial<Omit<CommandContext, "argv">> = {},
): Promise<number> {
const context: CommandContext = {
argv,
env: overrides.env ?? process.env,
platform: overrides.platform ?? process.platform,
home: overrides.home ?? homedir(),
fetchFn: overrides.fetchFn ?? fetch,
stdout: overrides.stdout ?? ((value) => { process.stdout.write(value); }),
stderr: overrides.stderr ?? ((value) => { process.stderr.write(value); }),
};
try {
const args = parseClientArgs(context.argv);
const paths = clientPaths(context.env, context.platform, context.home);
if (args.appRoot) Object.assign(paths, pathsForRoot(resolve(args.appRoot), context.platform));
assertSafeClientRoot(paths.root, context.home);
let result: Record<string, unknown>;
if (args.command === "install" || args.command === "update") result = await installClient(paths, args, context);
else if (args.command === "uninstall") result = uninstallClient(paths, args, context);
else if (args.command === "print-path") result = printPath(paths, args);
else if (args.command === "print-config") result = printConfig(paths, args);
else if (args.command === "doctor") result = doctor(paths, context);
else throw new Error(usage());
writeResult(result, args, context);
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (context.argv.includes("--json") || context.argv.includes("mcp-json")) context.stdout(`${JSON.stringify({ ok: false, error: message })}\n`);
else context.stderr(`${message}\n`);
return 1;
}
}
interface ParsedArgs {
command: string;
manifestUrl?: string;
publicKey?: string;
json: boolean;
yes: boolean;
allowDowngrade: boolean;
appRoot?: string;
pathKind?: string;
format?: "mcp-json";
}
function parseClientArgs(argv: string[]): ParsedArgs {
const values = [...argv];
if (values[0] === "--print-path") values.splice(0, 1, "print-path");
const command = values.shift() ?? "";
const strings = new Map<string, string>();
const flags = new Set<string>();
let pathKind: string | undefined;
const stringOptions = new Set(["--manifest-url", "--public-key", "--app-root", "--format"]);
const flagOptions = new Set(["--json", "--yes", "--allow-downgrade"]);
for (let index = 0; index < values.length; index += 1) {
const value = values[index]!;
if (stringOptions.has(value)) {
const next = values[index + 1];
if (!next || next.startsWith("--")) throw new Error(`${value} 缺少参数`);
if (strings.has(value)) throw new Error(`${value} 不能重复`);
strings.set(value, next);
index += 1;
} else if (flagOptions.has(value)) {
if (flags.has(value)) throw new Error(`${value} 不能重复`);
flags.add(value);
} else if (value.startsWith("--")) throw new Error(`未知参数:${value}`);
else if (command === "print-path" && !pathKind) pathKind = value;
else throw new Error(`多余参数:${value}`);
}
const allowed = new Set(command === "install" || command === "update"
? ["--manifest-url", "--public-key", "--app-root", "--json", "--allow-downgrade"]
: command === "uninstall" ? ["--app-root", "--json", "--yes"]
: command === "print-path" ? ["--app-root", "--json"]
: command === "print-config" ? ["--app-root", "--json", "--format"]
: command === "doctor" ? ["--app-root", "--json"] : []);
for (const name of [...strings.keys(), ...flags]) {
if (!allowed.has(name)) throw new Error(`${command || "该命令"} 不支持参数 ${name}`);
}
const format = strings.get("--format");
if (format && format !== "mcp-json") throw new Error("--format 只支持 mcp-json");
return {
command,
manifestUrl: strings.get("--manifest-url"),
publicKey: strings.get("--public-key"),
json: flags.has("--json"),
yes: flags.has("--yes"),
allowDowngrade: flags.has("--allow-downgrade"),
appRoot: strings.get("--app-root"),
pathKind,
format: format as "mcp-json" | undefined,
};
}
async function installClient(paths: ClientPaths, args: ParsedArgs, context: CommandContext): Promise<Record<string, unknown>> {
requireNodeVersion();
const npmCheck = checkNpm(context);
if (!npmCheck.ok) throw new Error(`未找到可用的 npm:${npmCheck.message}`);
const previous = readState(paths.stateFile);
const manifestUrl = normalizedHttpsUrl(args.manifestUrl ?? previous?.manifestUrl ?? "", "manifest URL");
if (!manifestUrl.pathname.endsWith("/manifest.json")) throw new Error("manifest URL 必须以 /manifest.json 结尾");
const publicKeyPath = resolvePublicKeyPath(paths, args.publicKey);
const publicKey = readFileSync(publicKeyPath);
const [manifestBytes, signatureBytes] = await Promise.all([
download(context.fetchFn, manifestUrl),
download(context.fetchFn, new URL("manifest.sig", manifestUrl)),
]);
const manifest = verifySignedManifest(manifestBytes, signatureBytes, publicKey, manifestUrl.toString());
if (previous && compareVersions(manifest.version, previous.version) < 0 && !args.allowDowngrade) {
throw new Error(`拒绝从 ${previous.version} 降级到 ${manifest.version};如确需降级请显式使用 --allow-downgrade`);
}
const temporary = mkdtempSync(join(tmpdir(), "dingtalk-approval-install-"));
try {
const clientArchive = join(temporary, "client.tgz");
await downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.client, clientArchive);
const skillBody = await downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.skill, join(temporary, "SKILL.md"));
const publicArtifact = await downloadArtifact(context.fetchFn, manifestUrl, manifest.artifacts.publicKey, join(temporary, "release-public.pem"));
if (releaseKeyId(publicArtifact) !== manifest.signing.keyId || !publicArtifact.equals(publicKey)) {
throw new Error("发布公钥与本机信任的公钥不一致,拒绝安装");
}
mkdirPrivate(paths.root);
mkdirPrivate(paths.versionsDir);
const versionName = `${safeName(manifest.version)}-${manifest.artifacts.client.sha256.slice(0, 12)}`;
const versionDir = join(paths.versionsDir, versionName);
const clientEntry = join(versionDir, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js");
const cliEntry = join(versionDir, "node_modules", "dingtalk-approval-client", "dist", "src", "client-installer.js");
if (!existsSync(clientEntry) || !existsSync(cliEntry)) {
if (existsSync(versionDir)) rmSync(versionDir, { recursive: true, force: true });
const staging = join(paths.versionsDir, `.staging-${process.pid}-${randomUUID()}`);
mkdirPrivate(staging);
const installed = runNpm(context, ["install", "--prefix", staging, "--offline", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", clientArchive]);
if (installed.status !== 0) {
rmSync(staging, { recursive: true, force: true });
throw new Error(`客户端运行时安装失败:${(installed.stderr || installed.stdout || "npm 执行失败").trim()}`);
}
if (!existsSync(join(staging, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js"))) {
rmSync(staging, { recursive: true, force: true });
throw new Error("客户端包缺少 Bridge 入口");
}
renameSync(staging, versionDir);
}
mkdirPrivate(paths.skillDir);
writeAtomic(paths.skillFile, skillBody, 0o600);
writeAtomic(paths.publicKeyFile, publicArtifact, 0o600);
writeLaunchers(paths, context.platform);
const state: ClientInstallState = {
schemaVersion: 1,
version: manifest.version,
installedAt: new Date().toISOString(),
manifestUrl: manifestUrl.toString(),
gatewayUrl: manifest.gatewayUrl,
clientSha256: manifest.artifacts.client.sha256,
skillSha256: manifest.artifacts.skill.sha256,
runtimeDir: relative(paths.root, versionDir),
skillFile: relative(paths.root, paths.skillFile),
};
writeAtomic(paths.stateFile, Buffer.from(`${JSON.stringify(state, null, 2)}\n`), 0o600);
const legacyCleanupFailed: string[] = [];
const legacyRemoved = cleanupLegacyInstallations(context.env, context.platform, context.home, legacyCleanupFailed);
cleanupOldVersions(paths, versionDir);
const postInstall = attemptPostInstallLogin(clientEntry, manifest.gatewayUrl, paths, context);
return installResult(paths, state, legacyRemoved, legacyCleanupFailed, postInstall);
} finally {
rmSync(temporary, { recursive: true, force: true });
}
}
function uninstallClient(paths: ClientPaths, args: ParsedArgs, context: CommandContext): Record<string, unknown> {
if (!args.yes) throw new Error("卸载会删除运行时、Skill 和 OAuth 登录会话;请使用 --yes 明确确认");
const state = readState(paths.stateFile);
if (existsSync(paths.root) && !state) throw new Error("客户端目录缺少有效安装记录,拒绝递归删除;请人工核对该目录");
const legacyCleanupFailed: string[] = [];
const legacyRemoved = cleanupLegacyInstallations(context.env, context.platform, context.home, legacyCleanupFailed);
const legacySession = join(context.env.XDG_CONFIG_HOME?.trim() || join(context.home, ".config"), "dingtalk-approval-bridge");
if (isSafeLegacySessionPath(legacySession, context.home)) rmSync(legacySession, { recursive: true, force: true });
const runningInsideRoot = isInside(paths.root, process.argv[1] ?? "");
if (existsSync(paths.root)) {
if (runningInsideRoot) scheduleSelfCleanup(paths.root);
else rmSync(paths.root, { recursive: true, force: true });
}
return {
ok: true,
status: runningInsideRoot ? "cleanup_scheduled" : "uninstalled",
removedVersion: state?.version,
legacyEntriesRemoved: legacyRemoved,
legacyCleanupFailed,
sessionRemoved: true,
hostCleanupRequired: true,
hostCleanup: "请按当前 Agent 的官方方式删除名为 dingtalk-approval 的 MCP/连接器条目。",
};
}
function printPath(paths: ClientPaths, args: ParsedArgs): Record<string, unknown> {
assertInstalled(paths);
if (args.pathKind && args.pathKind !== "bridge" && args.pathKind !== "skill" && args.pathKind !== "cli") {
throw new Error("print-path 只支持 bridge、skill 或 cli");
}
const values = { bridge: paths.bridgeCommand, skill: paths.skillDir, cli: paths.cliCommand };
const kind = args.pathKind as keyof typeof values | undefined;
return { ok: true, ...(kind ? { path: values[kind] } : values) };
}
function printConfig(paths: ClientPaths, args: ParsedArgs): Record<string, unknown> {
const state = assertInstalled(paths);
if (args.format === "mcp-json") return mcpConfig(paths);
return { ok: true, version: state.version, mcpConfig: mcpConfig(paths), skillPath: paths.skillDir };
}
function doctor(paths: ClientPaths, context: CommandContext): Record<string, unknown> {
const checks: Array<Record<string, unknown>> = [];
const majorMinor = process.versions.node.split(".").slice(0, 2).map(Number);
checks.push({ name: "node", ok: (majorMinor[0] ?? 0) > 22 || ((majorMinor[0] ?? 0) === 22 && (majorMinor[1] ?? 0) >= 13), value: process.version });
const npm = checkNpm(context);
checks.push({ name: "npm", ...npm });
const state = readState(paths.stateFile);
checks.push({ name: "state", ok: Boolean(state), path: paths.stateFile });
checks.push({ name: "bridge", ok: existsSync(paths.bridgeCommand), path: paths.bridgeCommand });
checks.push({ name: "skill", ok: existsSync(paths.skillFile), path: paths.skillFile });
checks.push({ name: "publicKey", ok: existsSync(paths.publicKeyFile), path: paths.publicKeyFile });
if (state) {
const runtime = resolveInside(paths.root, state.runtimeDir);
checks.push({ name: "runtime", ok: existsSync(join(runtime, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js")), path: runtime });
try { normalizedHttpsUrl(state.gatewayUrl, "gateway URL"); checks.push({ name: "gateway", ok: true, value: state.gatewayUrl }); }
catch (error) { checks.push({ name: "gateway", ok: false, message: error instanceof Error ? error.message : String(error) }); }
}
const ok = checks.every((check) => check.ok === true);
return { ok, platform: context.platform, root: paths.root, checks };
}
function checkNpm(context: Pick<CommandContext, "env" | "platform">): { ok: boolean; value?: string; message?: string } {
const result = runNpm(context, ["--version"]);
if (result.error) return { ok: false, message: result.error.message };
if (result.status !== 0) return { ok: false, message: (result.stderr || result.stdout || `退出码 ${result.status}`).trim() };
const version = result.stdout.trim();
return version ? { ok: true, value: version } : { ok: false, message: "npm --version 未返回版本号" };
}
function runNpm(context: Pick<CommandContext, "env" | "platform">, args: string[]) {
const invocation = npmInvocation(context, args);
return spawnSync(invocation.command, invocation.args, {
encoding: "utf8",
env: { ...context.env, npm_config_update_notifier: "false" },
windowsHide: true,
});
}
function npmInvocation(context: Pick<CommandContext, "env" | "platform">, args: string[]): { command: string; args: string[] } {
if (context.platform !== "win32") return { command: "npm", args };
const candidates = [
context.env.npm_execpath,
...(context.env.PATH ?? "").split(";").filter(Boolean).map((entry) => join(entry.replace(/^"|"$/g, ""), "node_modules", "npm", "bin", "npm-cli.js")),
join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"),
];
const npmCli = candidates.find((candidate) => candidate && existsSync(candidate));
if (!npmCli) throw new Error("Windows 环境未找到可由 Node 安全启动的 npm-cli.js;请修复 Node/npm 安装后重试");
return { command: process.execPath, args: [npmCli, ...args] };
}
export function cleanupLegacyInstallations(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
home = homedir(),
failed: string[] = [],
): string[] {
const removed: string[] = [];
for (const binDir of legacyBinDirectories(env, platform, home)) {
for (const name of LEGACY_BIN_NAMES) {
const candidates = platform === "win32" ? [name, `${name}.cmd`, `${name}.ps1`] : [name];
for (const candidate of candidates) {
const path = join(binDir, candidate);
if (!existsOrSymlink(path) || !legacyEntryOwned(path)) continue;
try { unlinkSync(path); removed.push(path); }
catch { failed.push(path); }
}
}
const packageRoot = platform === "win32"
? join(binDir, "node_modules", "dingtalk-approval-mcp")
: resolve(binDir, "..", "lib", "node_modules", "dingtalk-approval-mcp");
if (exactPackage(packageRoot, "dingtalk-approval-mcp")) {
try { rmSync(packageRoot, { recursive: true, force: true }); removed.push(packageRoot); }
catch { failed.push(packageRoot); }
}
}
return removed;
}
function legacyBinDirectories(env: NodeJS.ProcessEnv, platform: NodeJS.Platform, home: string): string[] {
const paths = new Set((env.PATH ?? "").split(platform === "win32" ? ";" : ":").filter(Boolean).map((value) => resolve(value)));
if (platform === "win32") {
if (env.APPDATA) paths.add(resolve(env.APPDATA, "npm"));
} else {
paths.add(resolve(home, ".local", "bin"));
paths.add(resolve(home, ".volta", "bin"));
const nvmVersions = join(home, ".nvm", "versions", "node");
if (existsSync(nvmVersions)) {
for (const version of readdirSync(nvmVersions)) paths.add(resolve(nvmVersions, version, "bin"));
}
}
return [...paths].filter((path) => existsSync(path));
}
function legacyEntryOwned(path: string): boolean {
try {
const stat = lstatSync(path);
if (stat.isSymbolicLink()) return /(?:^|[\\/])dingtalk-approval-mcp(?:[\\/]|$)/.test(readlinkSync(path));
if (stat.isFile() && /\.(?:cmd|ps1)$/i.test(path)) {
return /(?:^|[\\/])dingtalk-approval-mcp(?:[\\/]|$)/.test(readFileSync(path, "utf8"));
}
} catch { return false; }
return false;
}
function installResult(
paths: ClientPaths,
state: ClientInstallState,
legacyRemoved: string[],
legacyCleanupFailed: string[],
postInstall: PostInstallResult,
): Record<string, unknown> {
const nextSteps = [
"按当前 Agent 的官方方式安装 skillPath,并添加名为 dingtalk-approval 的 stdio MCP。",
"立即处理 postInstall:若有 authChallenge.directUrl,必须在当前回复中原样展示,不得声称已另行推送,也不得等待用户首次查询审批。",
"用户授权后调用 approval_login;登录成功后按 verifyTool 做一次双来源只读验证,并展示一次性使用示例。",
"宿主若不能热加载 MCP,请新建会话或完整重启;恢复上述登录流程后再宣告安装完成,不得执行任何审批写操作。",
];
return {
ok: true,
status: "installed",
version: state.version,
root: paths.root,
bridgePath: paths.bridgeCommand,
cliPath: paths.cliCommand,
skillPath: paths.skillDir,
mcpConfig: mcpConfig(paths),
legacyEntriesRemoved: legacyRemoved,
legacyCleanupFailed,
configurationModified: false,
postInstall,
nextSteps,
};
}
function attemptPostInstallLogin(
clientEntry: string,
gatewayUrl: string,
paths: ClientPaths,
context: Pick<CommandContext, "env">,
): PostInstallResult {
const shared = {
required: true as const,
attempted: true as const,
confirmTool: { name: "approval_login" as const, arguments: {} },
verifyTool: { name: "approval_list_todos" as const, arguments: { source: "all" as const, query: "" as const, limit: 1 as const } },
completionCriteria: "登录确认并完成钉钉 OA 与薪福通双来源只读验证",
};
const result = spawnSync(process.execPath, [clientEntry, "--url", gatewayUrl, "--post-install-login"], {
encoding: "utf8",
timeout: 30_000,
maxBuffer: 256 * 1024,
env: { ...context.env, DINGTALK_APPROVAL_BRIDGE_STATE: paths.sessionFile },
});
if (result.error || result.status !== 0) {
return {
...shared,
status: "unavailable",
authenticated: false,
message: "客户端已安装,但暂时无法连接登录服务;请加载 MCP 后调用 approval_login 重试。",
};
}
const line = result.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
let value: Record<string, unknown>;
try { value = JSON.parse(line ?? "") as Record<string, unknown>; }
catch {
return {
...shared,
status: "unavailable",
authenticated: false,
message: "客户端已安装,但登录准备结果无法识别;请加载 MCP 后调用 approval_login 重试。",
};
}
const onboarding = safeOnboarding(value.onboarding);
if (value.authenticated === true) {
return {
...shared,
status: "authenticated",
authenticated: true,
message: typeof value.message === "string" ? value.message : "已复用现有登录,可以读取审批。",
...(onboarding ? { onboarding } : {}),
};
}
const challenge = value.authChallenge && typeof value.authChallenge === "object" && !Array.isArray(value.authChallenge)
? value.authChallenge as Record<string, unknown> : undefined;
if (challenge && typeof challenge.directUrl === "string") {
return {
...shared,
status: "authorization_required",
authenticated: false,
message: typeof value.message === "string" ? value.message : "需要完成钉钉授权登录。",
authChallenge: {
directUrl: challenge.directUrl,
...(typeof challenge.userCode === "string" ? { userCode: challenge.userCode } : {}),
...(typeof challenge.expiresAt === "string" ? { expiresAt: challenge.expiresAt } : {}),
},
};
}
return {
...shared,
status: "unavailable",
authenticated: false,
message: "客户端已安装,但登录服务未返回可用授权链接;请加载 MCP 后调用 approval_login 重试。",
};
}
function safeOnboarding(value: unknown): PostInstallResult["onboarding"] | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const record = value as Record<string, unknown>;
if (record.firstRun !== true || !Array.isArray(record.examples)) return undefined;
const examples = record.examples.filter((item): item is string => typeof item === "string").slice(0, 8);
if (!examples.length) return undefined;
return {
firstRun: true,
examples,
...(typeof record.writeSafety === "string" ? { writeSafety: record.writeSafety } : {}),
};
}
function mcpConfig(paths: ClientPaths): Record<string, unknown> {
return { mcpServers: { "dingtalk-approval": { type: "stdio", command: paths.bridgeCommand, args: [] } } };
}
function writeLaunchers(paths: ClientPaths, platform: NodeJS.Platform): void {
mkdirPrivate(paths.binDir);
const launcher = `#!/usr/bin/env node\nimport { readFileSync } from "node:fs";\nimport { dirname, join, resolve } from "node:path";\nimport { fileURLToPath } from "node:url";\nimport { spawn } from "node:child_process";\nconst mode = process.argv[2];\nconst root = resolve(dirname(fileURLToPath(import.meta.url)), "..");\nconst state = JSON.parse(readFileSync(join(root, "install-state.json"), "utf8"));\nconst runtime = resolve(root, state.runtimeDir);\nconst target = join(runtime, "node_modules", "dingtalk-approval-client", "dist", "src", mode === "bridge" ? "bridge.js" : "client-installer.js");\nconst args = mode === "bridge" ? ["--url", state.gatewayUrl, ...process.argv.slice(3)] : [...process.argv.slice(3), "--app-root", root];\nconst env = mode === "bridge" ? { ...process.env, DINGTALK_APPROVAL_BRIDGE_STATE: join(root, "state", "session.json") } : process.env;\nconst child = spawn(process.execPath, [target, ...args], { stdio: "inherit", env });\nchild.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exitCode = code ?? 1; });\nchild.on("error", (error) => { console.error(error.message); process.exitCode = 1; });\n`;
writeAtomic(paths.launcherFile, Buffer.from(launcher), 0o700);
if (platform === "win32") {
writeAtomic(paths.cliCommand, Buffer.from(`@echo off\r\nnode "%~dp0launcher.mjs" cli %*\r\n`), 0o700);
writeAtomic(paths.bridgeCommand, Buffer.from(`@echo off\r\nnode "%~dp0launcher.mjs" bridge %*\r\n`), 0o700);
} else {
writeAtomic(paths.cliCommand, Buffer.from(`#!/usr/bin/env sh\nexec node "$(dirname "$0")/launcher.mjs" cli "$@"\n`), 0o700);
writeAtomic(paths.bridgeCommand, Buffer.from(`#!/usr/bin/env sh\nexec node "$(dirname "$0")/launcher.mjs" bridge "$@"\n`), 0o700);
}
}
function validateManifest(manifest: ClientReleaseManifest, manifestUrl: string, keyId: string): void {
if (manifest.schemaVersion !== 3 || !VERSION_PATTERN.test(manifest.version)) throw new Error("发布清单版本或版本号无效");
if (manifest.signing?.algorithm !== "Ed25519" || manifest.signing.keyId !== keyId) throw new Error("发布清单未绑定当前发布公钥");
const base = normalizedHttpsUrl(manifestUrl, "manifest URL");
const gateway = normalizedHttpsUrl(manifest.gatewayUrl, "gateway URL");
if (gateway.pathname !== "/mcp" || gateway.search || gateway.hash) throw new Error("gateway URL 必须是 HTTPS /mcp 地址");
for (const [name, artifact] of Object.entries(manifest.artifacts ?? {})) {
if (!artifact || !/^[a-f0-9]{64}$/.test(artifact.sha256)) throw new Error(`发布物 ${name} 缺少有效 SHA-256`);
const url = normalizedHttpsUrl(artifact.url, `${name} URL`);
if (url.origin !== base.origin) throw new Error(`发布物 ${name} 必须与清单同源`);
}
if (manifest.artifacts.publicKey.keyId !== keyId) throw new Error("发布公钥 artifact 的 keyId 不匹配");
const createdAt = Date.parse(manifest.createdAt);
if (!Number.isFinite(createdAt)) throw new Error("发布清单 createdAt 无效");
if (createdAt > Date.now() + 24 * 60 * 60_000) throw new Error("发布清单 createdAt 来自未来,拒绝安装");
if (createdAt < Date.now() - MANIFEST_MAX_AGE_MS) throw new Error("发布清单已超过 180 天有效期,请获取新的安装链接");
}
async function download(fetchFn: typeof fetch, url: URL): Promise<Buffer> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(new Error("下载超时")), DOWNLOAD_TIMEOUT_MS);
timeout.unref();
try {
const response = await fetchFn(url, {
headers: { accept: "application/octet-stream, application/json;q=0.9, */*;q=0.8" },
signal: controller.signal,
});
if (!response.ok) throw new Error(`下载失败 ${response.status}:${url}`);
const length = Number(response.headers.get("content-length") ?? 0);
if (Number.isFinite(length) && length > MAX_DOWNLOAD_BYTES) throw new Error(`下载文件超过 25MB 限制:${url}`);
if (!response.body) return Buffer.alloc(0);
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MAX_DOWNLOAD_BYTES) {
try { await reader.cancel("download size limit exceeded"); } catch { /* size rejection still wins */ }
throw new Error(`下载文件超过 25MB 限制:${url}`);
}
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, total);
} catch (error) {
if (controller.signal.aborted) throw new Error(`下载超时(30 秒):${url}`, { cause: error });
throw error;
} finally {
clearTimeout(timeout);
}
}
async function downloadArtifact(fetchFn: typeof fetch, manifestUrl: URL, artifact: ReleaseArtifact, target: string): Promise<Buffer> {
const url = normalizedHttpsUrl(artifact.url, "artifact URL");
if (url.origin !== manifestUrl.origin) throw new Error("发布物必须与清单同源");
const body = await download(fetchFn, url);
if (sha256(body) !== artifact.sha256) throw new Error(`发布物 SHA-256 不匹配:${basename(url.pathname)}`);
writeAtomic(target, body, 0o600);
return body;
}
function resolvePublicKeyPath(paths: ClientPaths, explicit: string | undefined): string {
if (explicit) return resolve(explicit);
if (existsSync(paths.publicKeyFile)) return paths.publicKeyFile;
const adjacent = resolve(dirname(process.argv[1] ?? fileURLToPath(import.meta.url)), "release-signing-public.pem");
if (existsSync(adjacent)) return adjacent;
throw new Error("找不到发布公钥;首次安装必须提供 --public-key,更新则使用已固定的本地公钥");
}
function requireNodeVersion(): void {
const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
if (major < 22 || (major === 22 && minor < 13)) throw new Error(`需要 Node.js >=22.13.0,当前为 ${process.version}`);
}
function compareVersions(left: string, right: string): number {
const a = VERSION_PATTERN.exec(left);
const b = VERSION_PATTERN.exec(right);
if (!a || !b) throw new Error("无法比较非法版本号");
for (let index = 1; index <= 3; index += 1) {
const difference = Number(a[index]) - Number(b[index]);
if (difference !== 0) return Math.sign(difference);
}
const arc = a[4] === undefined ? Number.POSITIVE_INFINITY : Number(a[4]);
const brc = b[4] === undefined ? Number.POSITIVE_INFINITY : Number(b[4]);
return Math.sign(arc - brc);
}
function readState(path: string): ClientInstallState | undefined {
if (!existsSync(path)) return undefined;
try {
const value = JSON.parse(readFileSync(path, "utf8")) as ClientInstallState;
return value.schemaVersion === 1 && VERSION_PATTERN.test(value.version) ? value : undefined;
} catch { return undefined; }
}
function assertInstalled(paths: ClientPaths): ClientInstallState {
const state = readState(paths.stateFile);
if (!state) throw new Error("客户端尚未安装或安装状态已损坏,请重新运行 install");
return state;
}
function cleanupOldVersions(paths: ClientPaths, keep: string): void {
if (!existsSync(paths.versionsDir)) return;
for (const entry of readdirSync(paths.versionsDir)) {
const path = join(paths.versionsDir, entry);
if (resolve(path) === resolve(keep)) continue;
try { rmSync(path, { recursive: true, force: true }); } catch { /* Windows may keep the currently running old CLI open. */ }
}
}
function exactPackage(root: string, name: string): boolean {
try {
const metadata = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { name?: string };
return metadata.name === name;
} catch { return false; }
}
function existsOrSymlink(path: string): boolean {
try { lstatSync(path); return true; } catch { return false; }
}
function normalizedHttpsUrl(value: string, label: string): URL {
let url: URL;
try { url = new URL(value); } catch { throw new Error(`${label} 无效`); }
if (url.protocol !== "https:" || url.username || url.password) throw new Error(`${label} 必须是无内嵌凭证的 HTTPS URL`);
return url;
}
function sha256(value: Buffer): string { return createHash("sha256").update(value).digest("hex"); }
function safeName(value: string): string { return value.replace(/[^a-zA-Z0-9.-]/g, "-"); }
function mkdirPrivate(path: string): void {
mkdirSync(path, { recursive: true, mode: 0o700 });
if (process.platform !== "win32") chmodSync(path, 0o700);
}
function writeAtomic(path: string, body: Buffer, mode: number): void {
mkdirPrivate(dirname(path));
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
writeFileSync(temporary, body, { mode });
if (process.platform !== "win32") chmodSync(temporary, mode);
renameSync(temporary, path);
}
function pathsForRoot(root: string, platform: NodeJS.Platform): ClientPaths {
return clientPaths({ DINGTALK_APPROVAL_CLIENT_HOME: root }, platform, dirname(root));
}
function resolveInside(root: string, child: string): string {
const result = resolve(root, child);
if (!isInside(root, result)) throw new Error("安装状态包含越界路径");
return result;
}
function isInside(root: string, child: string): boolean {
if (!child) return false;
const value = relative(resolve(root), resolve(child));
return value === "" || (!value.startsWith(`..${sep}`) && value !== ".." && !isAbsolute(value));
}
function isSafeLegacySessionPath(path: string, home: string): boolean {
return isInside(home, path) && basename(path) === "dingtalk-approval-bridge";
}
function assertSafeClientRoot(root: string, home: string): void {
const resolved = resolve(root);
const name = basename(resolved).toLowerCase();
if (resolved === resolve(home) || dirname(resolved) === resolved || !["dingtalk-approval", "dingtalkapproval"].includes(name)) {
throw new Error("客户端目录必须是专用的 dingtalk-approval 用户数据目录");
}
}
function scheduleSelfCleanup(root: string): void {
const cleanupRoot = mkdtempSync(join(tmpdir(), "dingtalk-approval-cleanup-"));
const helper = join(cleanupRoot, "cleanup.mjs");
writeFileSync(helper, `import { rmSync } from "node:fs";\nsetTimeout(() => { rmSync(${JSON.stringify(root)}, { recursive: true, force: true }); rmSync(${JSON.stringify(cleanupRoot)}, { recursive: true, force: true }); }, 750);\n`, { mode: 0o700 });
const child = spawn(process.execPath, [helper], { detached: true, stdio: "ignore" });
child.unref();
}
function writeResult(value: Record<string, unknown>, args: ParsedArgs, context: Pick<CommandContext, "stdout">): void {
if (args.command === "print-path" && args.pathKind && !args.json) {
context.stdout(`${String(value.path ?? "")}\n`);
return;
}
if (args.json) context.stdout(`${JSON.stringify(value)}\n`);
else context.stdout(`${JSON.stringify(value, null, 2)}\n`);
}
function usage(): string {
return "用法:dingtalk-approval install|update|uninstall|print-path|print-config|doctor [--manifest-url URL] [--format mcp-json] [--json]";
}
if (isClientDirectEntrypoint(import.meta.url, process.argv[1])) {
runClientInstaller().then((code) => { process.exitCode = code; });
}
function isClientDirectEntrypoint(moduleUrl: string, argv1: string | undefined): boolean {
if (!argv1) return false;
const modulePath = fileURLToPath(moduleUrl);
try { return realpathSync(modulePath) === realpathSync(argv1); }
catch { return resolve(modulePath) === resolve(argv1); }
}
+227
View File
@@ -0,0 +1,227 @@
import { readFileSync, statSync } from "node:fs";
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js";
export interface AppConfig {
appMode?: "gateway";
clientId: string;
clientSecret: string;
corpId?: string;
apiBaseUrl: string;
oapiBaseUrl: string;
publicBaseUrl: string;
bindHost?: string;
port: number;
requestTimeoutMs: number;
officialLookbackDays: number;
officialScanConcurrency: number;
officialClientOnlyProcessCodes: string[];
batchLimit: number;
approvalRefSecret: string;
/** Internal identity used only to bind local opaque references; never selected by Agent input. */
trustedLocalUser?: { userId: string; name?: string };
remoteAuth?: {
issuerUrl: string;
databasePath: string;
sessionSecret: string;
accessTokenTtlSeconds: number;
refreshTokenTtlSeconds: number;
};
yida?: {
adminUserId: string;
organizationToken: string;
};
xftOpenApi?: {
appId: string;
authoritySecret: string;
enterpriseId: string;
apiBaseUrl: string;
encrypted: boolean;
dingTalkAppCode: string;
};
}
interface CredentialsFile {
clientId?: unknown;
clientSecret?: unknown;
}
function required(env: NodeJS.ProcessEnv, name: string): string {
const value = env[name]?.trim();
if (!value) throw new Error(`缺少必填环境变量 ${name}`);
return value;
}
function optional(env: NodeJS.ProcessEnv, name: string): string | undefined {
const value = env[name]?.trim();
return value || undefined;
}
function dingTalkCredential(env: NodeJS.ProcessEnv, envName: string, fileKey: keyof CredentialsFile): string {
const direct = env[envName]?.trim();
if (direct) return direct;
const path = env.DINGTALK_CREDENTIALS_FILE?.trim();
if (!path) throw new Error(`缺少必填环境变量 ${envName}(或配置 DINGTALK_CREDENTIALS_FILE)`);
if (process.platform !== "win32" && (statSync(path).mode & 0o077) !== 0) {
throw new Error("DINGTALK_CREDENTIALS_FILE 权限必须为 0600 或更严格");
}
let parsed: CredentialsFile;
try {
parsed = JSON.parse(readFileSync(path, "utf8")) as CredentialsFile;
} catch (error) {
throw new Error(`无法读取钉钉凭证文件 ${path}:${error instanceof Error ? error.message : String(error)}`);
}
const value = parsed[fileKey];
if (typeof value !== "string" || !value.trim()) throw new Error(`钉钉凭证文件缺少 ${fileKey}`);
return value.trim();
}
function secretFileValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
const path = env[name]?.trim();
if (!path) return undefined;
const value = readFileSync(path, "utf8").trim();
if (!value) throw new Error(`${name} 指向的密钥文件为空`);
return value;
}
function privateSecretFileValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
const path = env[name]?.trim();
if (!path) return undefined;
if (process.platform !== "win32" && (statSync(path).mode & 0o077) !== 0) {
throw new Error(`${name} 指向的密钥文件必须为 0600 或更严格权限`);
}
return secretFileValue(env, name);
}
function strongSecret(value: string | undefined, name: string): string {
if (!value) throw new Error(`缺少必填密钥 ${name}`);
if (Buffer.byteLength(value, "utf8") < 32) throw new Error(`${name} 必须至少包含 32 字节高熵随机值`);
return value;
}
function protectedAuthoritySecretFileValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
const path = env[name]?.trim();
if (!path) return undefined;
const mode = statSync(path).mode & 0o777;
if ((mode & 0o077) !== 0) throw new Error(`${name} 指向的密钥文件必须为 0600 或更严格权限`);
const value = secretFileValue(env, name);
if (!value) return undefined;
const lines = value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
// The XFT portal exports API Key on line 1 and AuthoritySecret on line 2.
// A legacy file containing only AuthoritySecret remains supported.
return lines.length >= 2 ? lines[1] : lines[0];
}
function positiveInteger(env: NodeJS.ProcessEnv, name: string, fallback: number): number {
const raw = env[name]?.trim();
if (!raw) return fallback;
const value = Number(raw);
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} 必须是正整数`);
return value;
}
function booleanValue(env: NodeJS.ProcessEnv, name: string, fallback: boolean): boolean {
const raw = env[name]?.trim().toLowerCase();
if (!raw) return fallback;
if (["1", "true", "yes"].includes(raw)) return true;
if (["0", "false", "no"].includes(raw)) return false;
throw new Error(`${name} 必须是 true 或 false`);
}
function commaSeparated(env: NodeJS.ProcessEnv, name: string): string[] {
return [...new Set((env[name] ?? "").split(",").map((value) => value.trim()).filter(Boolean))];
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const configuredMode = optional(env, "APP_MODE");
if (!configuredMode || !["gateway", "xft-gateway"].includes(configuredMode)) {
throw new Error("必须显式设置 APP_MODE=gateway");
}
const appMode = "gateway" as const;
const port = positiveInteger(env, "PORT", 3000);
if (port > 65535) throw new Error("PORT 必须在 1 到 65535 之间");
const publicBaseUrl = (optional(env, "MCP_PUBLIC_BASE_URL") ?? `http://localhost:${port}`).replace(/\/$/, "");
const parsedUrl = new URL(publicBaseUrl);
if (parsedUrl.protocol !== "https:" && !(parsedUrl.protocol === "http:" && isLoopbackHost(parsedUrl.hostname))) {
throw new Error("MCP_PUBLIC_BASE_URL 必须使用 HTTPS;仅本机开发允许 HTTP 回环地址");
}
if (parsedUrl.username || parsedUrl.password || parsedUrl.pathname !== "/" || parsedUrl.search || parsedUrl.hash) {
throw new Error("MCP_PUBLIC_BASE_URL 只能包含协议、域名和可选端口");
}
const yidaEnabled = booleanValue(env, "YIDA_ENABLED", false);
const xftOpenApiEnabled = booleanValue(env, "XFT_OPEN_API_ENABLED", false);
const bindHost = optional(env, "MCP_BIND_HOST") ?? "127.0.0.1";
const remoteAuthEnabled = booleanValue(env, "MCP_REMOTE_AUTH_ENABLED", !isLoopbackHost(parsedUrl.hostname));
if (optional(env, "MCP_TRUSTED_LOCAL_USER_ID") || optional(env, "MCP_TRUSTED_LOCAL_USER_NAME")) {
throw new Error("新架构禁止配置 MCP_TRUSTED_LOCAL_USER_ID/NAME");
}
if (!isLoopbackHost(bindHost)) {
throw new Error("MCP_BIND_HOST 仅允许回环地址");
}
if (remoteAuthEnabled && !optional(env, "DINGTALK_CORP_ID")) {
throw new Error("远程 OAuth 模式必须配置 DINGTALK_CORP_ID");
}
if (booleanValue(env, "DWS_ENABLED", false) || optional(env, "DWS_CLI_PATH") || optional(env, "XFT_GATEWAY_URL")) {
throw new Error("统一网关架构禁止配置本地 DWS 或二级 XFT 网关");
}
if (!remoteAuthEnabled || !xftOpenApiEnabled) {
throw new Error("gateway 模式必须启用 MCP 远程 OAuth 和薪福通正式 OpenAPI");
}
return {
appMode,
clientId: dingTalkCredential(env, "DINGTALK_CLIENT_ID", "clientId"),
clientSecret: dingTalkCredential(env, "DINGTALK_CLIENT_SECRET", "clientSecret"),
...(optional(env, "DINGTALK_CORP_ID") ? { corpId: optional(env, "DINGTALK_CORP_ID") } : {}),
apiBaseUrl: (env.DINGTALK_API_BASE_URL?.trim() || "https://api.dingtalk.com").replace(/\/$/, ""),
oapiBaseUrl: (env.DINGTALK_OAPI_BASE_URL?.trim() || "https://oapi.dingtalk.com").replace(/\/$/, ""),
publicBaseUrl,
bindHost,
port,
requestTimeoutMs: positiveInteger(env, "DINGTALK_REQUEST_TIMEOUT_MS", 15_000),
officialLookbackDays: Math.min(120, positiveInteger(env, "OFFICIAL_OA_LOOKBACK_DAYS", 120)),
officialScanConcurrency: positiveInteger(env, "OFFICIAL_OA_SCAN_CONCURRENCY", 2),
officialClientOnlyProcessCodes: commaSeparated(env, "DINGTALK_CLIENT_ONLY_PROCESS_CODES"),
batchLimit: APPROVAL_BATCH_LIMIT,
approvalRefSecret: strongSecret(
privateSecretFileValue(env, "MCP_APPROVAL_REF_SECRET_FILE") ?? optional(env, "MCP_APPROVAL_REF_SECRET"),
"MCP_APPROVAL_REF_SECRET(或 MCP_APPROVAL_REF_SECRET_FILE)",
),
...(remoteAuthEnabled ? {
remoteAuth: {
issuerUrl: parsedUrl.origin,
databasePath: optional(env, "MCP_AUTH_DATABASE") ?? ".runtime/auth.sqlite",
sessionSecret: strongSecret(
privateSecretFileValue(env, "MCP_SESSION_SECRET_FILE") ?? optional(env, "MCP_SESSION_SECRET"),
"MCP_SESSION_SECRET(或 MCP_SESSION_SECRET_FILE)",
),
accessTokenTtlSeconds: positiveInteger(env, "MCP_ACCESS_TOKEN_TTL_SECONDS", 15 * 60),
refreshTokenTtlSeconds: positiveInteger(env, "MCP_REFRESH_TOKEN_TTL_SECONDS", 30 * 24 * 60 * 60),
},
} : {}),
...(yidaEnabled
? {
yida: {
adminUserId: required(env, "YIDA_ADMIN_USER_ID"),
organizationToken: required(env, "YIDA_ORGANIZATION_TOKEN"),
},
}
: {}),
...(xftOpenApiEnabled
? {
xftOpenApi: {
appId: required(env, "XFT_OPEN_API_APP_ID"),
authoritySecret: protectedAuthoritySecretFileValue(env, "XFT_OPEN_API_AUTHORITY_SECRET_FILE")
?? required(env, "XFT_OPEN_API_AUTHORITY_SECRET"),
enterpriseId: required(env, "XFT_OPEN_API_ENTERPRISE_ID"),
apiBaseUrl: (env.XFT_OPEN_API_BASE_URL?.trim() || "https://api.cmbchina.com").replace(/\/$/, ""),
encrypted: booleanValue(env, "XFT_OPEN_API_ENCRYPTED", true),
dingTalkAppCode: optional(env, "XFT_OPEN_API_DINGTALK_APP_CODE") ?? "DDingTalk",
},
}
: {}),
};
}
function isLoopbackHost(value: string): boolean {
return value === "localhost" || value === "127.0.0.1" || value === "::1" || value === "[::1]";
}
+494
View File
@@ -0,0 +1,494 @@
import type { AppConfig } from "./config.js";
import { DingTalkApiError } from "./errors.js";
import { numberishStringValue, numericValue, objectValue, stringValue } from "./json.js";
export type ApprovalAction = "agree" | "refuse";
export type ApprovalSource = "official_oa" | "yida";
export type FetchLike = typeof fetch;
export interface ApprovalTask {
taskId?: number | string;
userId?: string;
status?: string;
result?: string;
[key: string]: unknown;
}
export interface ApprovalInstance {
processInstanceId?: string;
title?: string;
status?: string;
result?: string;
tasks?: ApprovalTask[];
[key: string]: unknown;
}
export interface OfficialTemplate {
name?: string;
processCode: string;
dirName?: string;
[key: string]: unknown;
}
export interface OfficialFormValue { name: string; value: string }
export interface OfficialSubmissionRequest {
processCode: string;
originatorUserId: string;
deptId: number;
formComponentValues: OfficialFormValue[];
targetSelectActioners?: Array<{ actionerKey: string; actionerStaffIds: string[] }>;
}
export interface OfficialTodoWorkRecord {
instance_id?: string;
task_id?: number | string;
title?: string;
url?: string;
forms?: Array<{ title?: string; content?: string }>;
[key: string]: unknown;
}
export interface OfficialInitiatedInstancePage {
instanceIds: string[];
nextCursor?: number;
}
export interface YidaTask {
processInstanceId?: string;
formInstId?: string;
taskId?: string;
appType?: string;
title?: string;
processInstanceTitle?: string;
status?: string;
taskStatus?: string;
originatorId?: string;
[key: string]: unknown;
}
export interface YidaApplication {
appType: string;
systemToken: string;
name?: string;
[key: string]: unknown;
}
interface CachedToken { value: string; expiresAt: number }
export class DingTalkClient {
private token?: CachedToken;
private tokenPromise: Promise<string> | undefined;
constructor(private readonly config: AppConfig, private readonly fetchFn: FetchLike = fetch) {}
async getAppAccessToken(): Promise<string> {
if (this.token && this.token.expiresAt > Date.now() + 60_000) return this.token.value;
if (this.tokenPromise) return this.tokenPromise;
this.tokenPromise = this.fetchAppAccessToken();
try { return await this.tokenPromise; } finally { this.tokenPromise = undefined; }
}
async listOfficialTemplates(): Promise<OfficialTemplate[]> {
const output: OfficialTemplate[] = [];
let nextToken = 0;
for (let page = 0; page < 100; page += 1) {
const query = new URLSearchParams({ maxResults: "100", nextToken: String(nextToken) });
const data = await this.appRequest<Record<string, unknown>>(
`/v1.0/workflow/processes/userVisibilities/templates?${query}`,
{ method: "GET" },
);
const result = objectValue(data.result) ?? data;
const list = Array.isArray(result.processList) ? result.processList : [];
for (const item of list) {
const value = objectValue(item);
const processCode = stringValue(value?.processCode);
if (value && processCode) output.push({ ...value, processCode } as OfficialTemplate);
}
const next = numericValue(result.nextToken);
if (!next || list.length === 0) break;
nextToken = next;
}
return output;
}
async getOfficialTemplateSchema(processCode: string): Promise<Record<string, unknown>> {
const query = new URLSearchParams({ processCode });
const data = await this.appRequest<Record<string, unknown>>(
`/v1.0/workflow/forms/schemas/processCodes?${query}`,
{ method: "GET" },
);
return objectValue(data.result) ?? data;
}
async getUserDepartmentIds(userId: string): Promise<number[]> {
const accessToken = await this.getAppAccessToken();
const data = await this.publicRequest<Record<string, unknown>>(
`${this.config.oapiBaseUrl}/topapi/v2/user/get?access_token=${encodeURIComponent(accessToken)}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ userid: userId, language: "zh_CN" }),
},
);
const result = objectValue(data.result) ?? data;
const raw = Array.isArray(result.deptIdList) ? result.deptIdList
: Array.isArray(result.dept_id_list) ? result.dept_id_list : [];
return [...new Set(raw.map((value) => Number(value)).filter((value) => Number.isSafeInteger(value) && value > 0))];
}
async forecastOfficialProcess(input: OfficialSubmissionRequest): Promise<Record<string, unknown>> {
const data = await this.appRequest<Record<string, unknown>>("/v1.0/workflow/processes/forecast", {
method: "POST",
body: JSON.stringify({
processCode: input.processCode,
userId: input.originatorUserId,
deptId: input.deptId,
formComponentValues: input.formComponentValues,
}),
});
const result = objectValue(data.result) ?? data;
const forecastSuccess = booleanField(result, ["isForecastSuccess", "forecastSuccess"]);
if (forecastSuccess === undefined) {
throw new DingTalkApiError("钉钉审批流程预测响应缺少预测结果", 502, "INVALID_FORECAST_RESPONSE");
}
if (!forecastSuccess) {
const detail = stringValue(result.message) ?? stringValue(result.errorMessage);
throw new DingTalkApiError(
`钉钉审批流程预测失败${detail ? `:${detail}` : ",请核对表单字段和发起部门"}`,
422,
"FORECAST_FAILED",
);
}
const rules = recordArrayField(result, ["workflowActivityRules", "workflowActivityRuleVOs"])
.map((rule) => ({
...rule,
targetSelect: booleanField(rule, ["isTargetSelect", "targetSelect"]) === true,
}));
const staticWorkflow = booleanField(result, ["isStaticWorkflow", "staticWorkflow"]);
const {
isForecastSuccess: _isForecastSuccess,
isStaticWorkflow: _isStaticWorkflow,
workflowActivityRules: _workflowActivityRules,
workflowActivityRuleVOs: _workflowActivityRuleVOs,
...rest
} = result;
return {
...rest,
supported: true,
forecastSuccess,
...(staticWorkflow !== undefined ? { staticWorkflow } : {}),
workflowActivityRuleVOs: rules,
};
}
async createOfficialInstance(input: OfficialSubmissionRequest): Promise<string> {
const { targetSelectActioners, ...request } = input;
const data = await this.appRequest<Record<string, unknown>>("/v1.0/workflow/processInstances", {
method: "POST",
body: JSON.stringify({
...request,
...(targetSelectActioners?.length ? {
targetSelectActioners: targetSelectActioners.map((item) => ({
actionerKey: item.actionerKey,
actionerUserIds: item.actionerStaffIds,
})),
} : {}),
}),
});
const result = data.result;
const id = typeof result === "string" ? result
: stringValue(objectValue(result)?.processInstanceId)
?? stringValue(objectValue(result)?.id)
?? stringValue(data.processInstanceId);
if (!id) throw new DingTalkApiError("钉钉已返回成功,但响应缺少审批实例 ID", 502);
return id;
}
async terminateOfficialInstance(input: {
processInstanceId: string;
operatingUserId: string;
remark?: string;
}): Promise<void> {
await this.appRequest("/v1.0/workflow/processInstances/terminate", {
method: "POST",
body: JSON.stringify({ ...input, isSystem: false }),
});
}
async getOfficialInstance(processInstanceId: string): Promise<ApprovalInstance> {
const query = new URLSearchParams({ processInstanceId });
const data = await this.appRequest<Record<string, unknown>>(
`/v1.0/workflow/processInstances?${query}`,
{ method: "GET" },
);
return (objectValue(data.result) ?? data) as ApprovalInstance;
}
async executeOfficialApproval(input: {
processInstanceId: string;
taskId: number | string;
actionerUserId: string;
result: ApprovalAction;
remark?: string;
}): Promise<unknown> {
const taskId = officialTaskId(input.taskId);
return this.appRequest("/v1.0/workflow/processInstances/execute", {
method: "POST",
body: JSON.stringify({ ...input, taskId }),
});
}
async listOfficialTodoWorkRecords(
userId: string,
offset = 0,
count = 50,
status: 0 | 1 = 0,
): Promise<{ list: OfficialTodoWorkRecord[]; hasMore: boolean }> {
const accessToken = await this.getAppAccessToken();
const data = await this.publicRequest<Record<string, unknown>>(
`${this.config.oapiBaseUrl}/topapi/process/workrecord/task/query?access_token=${encodeURIComponent(accessToken)}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ userid: userId, offset, count: Math.min(count, 20), status }),
},
);
const result = objectValue(data.result) ?? {};
const list = Array.isArray(result.list)
? result.list.filter((item): item is OfficialTodoWorkRecord => objectValue(item) !== undefined)
: [];
return { list, hasMore: result.has_more === true };
}
async listOfficialInitiatedInstanceIds(
processCode: string,
userId: string,
startTime: number,
endTime: number,
cursor = 0,
size = 20,
): Promise<OfficialInitiatedInstancePage> {
const accessToken = await this.getAppAccessToken();
const data = await this.publicRequest<Record<string, unknown>>(
`${this.config.oapiBaseUrl}/topapi/processinstance/listids?access_token=${encodeURIComponent(accessToken)}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
process_code: processCode,
userid: userId,
start_time: startTime,
end_time: endTime,
cursor,
size: Math.min(size, 20),
}),
},
);
const result = objectValue(data.result) ?? {};
const values = Array.isArray(result.list) ? result.list : [];
const instanceIds = values.flatMap((value) => {
const record = objectValue(value);
const id = numberishStringValue(value)
?? (record ? numberishStringValue(
record.process_instance_id ?? record.processInstanceId ?? record.instance_id ?? record.instanceId ?? record.id,
) : undefined);
return id ? [id] : [];
});
const nextCursor = numericValue(result.next_cursor ?? result.nextCursor);
return { instanceIds, ...(nextCursor !== undefined && nextCursor > cursor ? { nextCursor } : {}) };
}
async listYidaCorpTasks(userId: string, pageNumber = 1, pageSize = 100): Promise<Record<string, unknown>> {
const yida = this.requireYida();
const end = Date.now();
const start = end - this.config.officialLookbackDays * 86_400_000;
const query = new URLSearchParams({
corpId: this.requireCorpId(),
pageSize: String(Math.min(pageSize, 100)),
pageNumber: String(pageNumber),
language: "zh_CN",
createFromTimeGMT: String(start),
createToTimeGMT: String(end),
userId,
token: yida.organizationToken,
});
return this.appRequest(`/v1.0/yida/corpTasks?${query}`, { method: "GET" });
}
async listYidaApplications(): Promise<YidaApplication[]> {
const yida = this.requireYida();
const output: YidaApplication[] = [];
for (let page = 1; page <= 100; page += 1) {
const query = new URLSearchParams({
appFilter: "managedByMe",
pageNumber: String(page),
corpId: this.requireCorpId(),
pageSize: "100",
userId: yida.adminUserId,
token: yida.organizationToken,
});
const data = await this.appRequest<Record<string, unknown>>(
`/v1.0/yida/organizations/applications?${query}`,
{ method: "GET" },
);
const list = Array.isArray(data.data) ? data.data : [];
for (const item of list) {
const value = objectValue(item);
const appType = stringValue(value?.appType);
const systemToken = stringValue(value?.systemToken);
if (value && appType && systemToken) output.push({ ...value, appType, systemToken } as YidaApplication);
}
if (list.length < 100) break;
}
return output;
}
async getYidaInstance(id: string, app: YidaApplication, userId: string): Promise<unknown> {
const query = new URLSearchParams({ appType: app.appType, systemToken: app.systemToken, userId, language: "zh_CN" });
return this.appRequest(`/v1.0/yida/processes/instancesInfos/${encodeURIComponent(id)}?${query}`, { method: "GET" });
}
async executeYidaBatch(
app: YidaApplication,
userId: string,
action: ApprovalAction,
remark: string,
tasks: Array<{ taskId: string; formInstId: string }>,
): Promise<unknown> {
return this.appRequest("/v1.0/yida/tasks/batches/execute", {
method: "POST",
body: JSON.stringify({
outResult: action === "agree" ? "agree" : "disagree",
appType: app.appType,
systemToken: app.systemToken,
remark,
userId,
taskInformationList: JSON.stringify(tasks),
}),
});
}
private requireYida() {
if (!this.config.yida) throw new DingTalkApiError("服务器未启用宜搭接口", 503);
return this.config.yida;
}
private requireCorpId(): string {
const corpId = this.config.corpId;
if (!corpId) throw new DingTalkApiError("宜搭功能需要显式配置 DINGTALK_CORP_ID", 503);
return corpId;
}
private async fetchAppAccessToken(): Promise<string> {
const data = await this.publicRequest<Record<string, unknown>>(
`${this.config.apiBaseUrl}/v1.0/oauth2/accessToken`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ appKey: this.config.clientId, appSecret: this.config.clientSecret }),
},
);
const value = stringValue(data.accessToken);
const expireIn = numericValue(data.expireIn);
if (!value || !expireIn) throw new DingTalkApiError("钉钉应用 accessToken 响应格式无效", 502);
this.token = { value, expiresAt: Date.now() + expireIn * 1_000 };
return value;
}
private async appRequest<T>(path: string, init: RequestInit, allowTokenRetry = true): Promise<T> {
const token = await this.getAppAccessToken();
const headers = new Headers(init.headers);
headers.set("x-acs-dingtalk-access-token", token);
headers.set("accept", "application/json");
if (init.body) headers.set("content-type", "application/json");
try {
return await this.publicRequest<T>(`${this.config.apiBaseUrl}${path}`, { ...init, headers });
} catch (error) {
if (allowTokenRetry && isInvalidAppAccessToken(error)) {
this.token = undefined;
return this.appRequest<T>(path, init, false);
}
throw error;
}
}
private async publicRequest<T>(url: string, init: RequestInit): Promise<T> {
for (let attempt = 0; attempt < 4; attempt += 1) {
let response: Response;
try {
response = await this.fetchFn(url, { ...init, signal: AbortSignal.timeout(this.config.requestTimeoutMs) });
} catch (error) {
throw new DingTalkApiError(`请求钉钉接口失败:${error instanceof Error ? error.message : String(error)}`, 0);
}
let text: string;
try { text = await response.text(); }
catch (error) {
throw new DingTalkApiError(
`读取钉钉接口响应失败:${error instanceof Error ? error.message : String(error)}`,
0,
"RESPONSE_STREAM_ERROR",
);
}
let data: unknown;
try { data = text ? JSON.parse(text) : {}; }
catch { throw new DingTalkApiError(`钉钉接口返回非 JSON 响应(HTTP ${response.status})`, response.status); }
const body = objectValue(data) ?? {};
const legacyError = numericValue(body.errcode);
const code = stringValue(body.code) ?? (legacyError === undefined ? undefined : String(legacyError));
const message = stringValue(body.message) ?? stringValue(body.errmsg) ?? `钉钉接口请求失败(HTTP ${response.status})`;
if (!response.ok || (legacyError !== undefined && legacyError !== 0) || body.success === false) {
if (attempt < 3 && isRateLimited(response.status, code, message)) {
await delay(300 * (2 ** attempt) + Math.floor(Math.random() * 100));
continue;
}
throw new DingTalkApiError(
message,
response.status,
code,
stringValue(body.requestid) ?? stringValue(body.requestId),
);
}
return data as T;
}
throw new DingTalkApiError("钉钉接口限流重试失败", 429);
}
}
function isInvalidAppAccessToken(error: unknown): boolean {
if (!(error instanceof DingTalkApiError)) return false;
if (error.status === 401) return true;
return ["40014", "42001", "InvalidAccessToken", "InvalidAuthentication"].includes(error.code ?? "");
}
function officialTaskId(value: number | string): number {
const parsed = typeof value === "number" ? value : Number(value);
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
throw new DingTalkApiError("钉钉审批任务 ID 必须是安全正整数", 400);
}
return parsed;
}
function booleanField(record: Record<string, unknown>, keys: string[]): boolean | undefined {
for (const key of keys) {
if (typeof record[key] === "boolean") return record[key];
}
return undefined;
}
function recordArrayField(record: Record<string, unknown>, keys: string[]): Array<Record<string, unknown>> {
for (const key of keys) {
const value = record[key];
if (Array.isArray(value)) {
return value.flatMap((item) => {
const object = objectValue(item);
return object ? [object] : [];
});
}
}
return [];
}
function isRateLimited(status: number, code: string | undefined, message: string): boolean {
return status === 429 || /qps|throttl|rate.?limit|流控|限流/i.test(`${code ?? ""} ${message}`);
}
function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
+17
View File
@@ -0,0 +1,17 @@
import { realpathSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
/**
* npm global bins are symlinks. Compare real paths so packaged CLI entrypoints
* still start when process.argv[1] is the generated bin link.
*/
export function isDirectEntrypoint(moduleUrl: string, argv1: string | undefined): boolean {
if (!argv1) return false;
const modulePath = fileURLToPath(moduleUrl);
try {
return realpathSync(modulePath) === realpathSync(argv1);
} catch {
return resolve(modulePath) === resolve(argv1);
}
}
+40
View File
@@ -0,0 +1,40 @@
export class DingTalkApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly code?: string,
readonly requestId?: string,
) {
super(message);
this.name = "DingTalkApiError";
}
}
export class ApprovalSafetyError extends Error {
constructor(message: string) {
super(message);
this.name = "ApprovalSafetyError";
}
}
export class XftApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly code?: string,
) {
super(message);
this.name = "XftApiError";
}
}
export class XftSessionExpiredError extends XftApiError {
constructor(message = "薪福通登录已失效,请重新扫码登录") {
super(message, 401, "MSCOM04");
this.name = "XftSessionExpiredError";
}
}
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env node
import { loadConfig } from "./config.js";
import { startHttpServer } from "./server.js";
const config = loadConfig();
startHttpServer(config).catch((error: unknown) => {
console.error(`[dingtalk-approval-mcp] ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
process.exitCode = 1;
});
+828
View File
@@ -0,0 +1,828 @@
import { createHmac, randomBytes } from "node:crypto";
import { chmodSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
import type { AppConfig } from "./config.js";
import type { ApprovalService, ApprovalHistoryItem } from "./approval-service.js";
import { ApprovalSafetyError, errorMessage } from "./errors.js";
import { isRecord } from "./json.js";
import { sensitiveLabel } from "./approval-metadata.js";
import type {
ApprovalHistoryRange,
AuthenticatedDingTalkUser,
XftApprovalProvider,
XftHistoryItem,
} from "./xft.js";
export type InvoiceEvidenceKind =
| "invoice_code_number"
| "digital_invoice_number"
| "invoice_number"
| "attachment_hash"
| "expense_signature";
export interface InvoiceEvidence {
kind: InvoiceEvidenceKind;
strength: "exact" | "auxiliary" | "suspected";
normalized: string;
maskedDisplay: string;
}
export interface InvoiceDuplicateMatch {
source: "dingtalk" | "xft";
businessNo: string;
submittedAt?: string;
status: string;
matchKind: InvoiceEvidenceKind;
matchStrength: InvoiceEvidence["strength"];
invoice: string;
disposition: "blocking" | "warning";
}
export interface InvoiceDuplicateCheck {
status: "not_applicable" | "clear" | "confirmation_required" | "proceeding_after_confirmation" | "check_unavailable";
checkedSources: Array<"dingtalk" | "xft">;
coverageComplete: boolean;
invoiceEvidence: Array<Pick<InvoiceEvidence, "kind" | "strength" | "maskedDisplay">>;
matches: InvoiceDuplicateMatch[];
requiresUserConfirmation: boolean;
message: string;
sourceStatus: Record<string, Record<string, unknown>>;
}
interface StoredMatch {
invoice_hash: string;
match_kind: InvoiceEvidenceKind;
source: "dingtalk" | "xft";
business_no: string;
status: string;
submitted_at: number | null;
}
interface HistoryRecord {
source: "dingtalk" | "xft";
businessNo: string;
status: string;
submittedAt?: string;
detail: () => Promise<unknown>;
}
interface SyncResult {
checkedSources: Array<"dingtalk" | "xft">;
complete: boolean;
sourceStatus: Record<string, Record<string, unknown>>;
}
interface PreparedDeduplication {
duplicateCheck: InvoiceDuplicateCheck;
reservationId?: string;
}
const HISTORY_LIMIT = 200;
const HISTORY_DAYS = 30;
const DETAIL_CONCURRENCY = 3;
const MAX_VISITED_NODES = 75_000;
const MAX_EVIDENCE = 100;
const EVIDENCE_EXTRACTOR_VERSION = 1;
const FINGERPRINT_RETENTION_DAYS = 35;
const ACTIVE_STATUSES = new Set(["pending", "approved", "unknown", "outcome_uncertain"]);
/**
* Stores only keyed, irreversible invoice fingerprints and minimal approval
* metadata. Approval bodies, invoice numbers and attachments never reach SQL.
*/
export class InvoiceDedupStore {
private readonly db: DatabaseSync;
private readonly hmacKey: string;
constructor(databasePath: string, secret: string) {
const path = resolve(databasePath);
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
this.db = new DatabaseSync(path);
chmodSync(path, 0o600);
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;");
this.db.exec(`
CREATE TABLE IF NOT EXISTS invoice_indexed_approvals (
user_hash TEXT NOT NULL,
source TEXT NOT NULL,
business_no TEXT NOT NULL,
status TEXT NOT NULL,
submitted_at INTEGER,
detail_indexed_at INTEGER,
extractor_version INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_hash, source, business_no)
);
CREATE TABLE IF NOT EXISTS invoice_fingerprints (
user_hash TEXT NOT NULL,
invoice_hash TEXT NOT NULL,
match_kind TEXT NOT NULL,
source TEXT NOT NULL,
business_no TEXT NOT NULL,
status TEXT NOT NULL,
submitted_at INTEGER,
indexed_at INTEGER NOT NULL,
PRIMARY KEY (user_hash, invoice_hash, source, business_no),
FOREIGN KEY (user_hash, source, business_no)
REFERENCES invoice_indexed_approvals(user_hash, source, business_no)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS invoice_fingerprints_lookup
ON invoice_fingerprints(user_hash, invoice_hash);
CREATE INDEX IF NOT EXISTS invoice_approvals_retention
ON invoice_indexed_approvals(COALESCE(submitted_at, updated_at));
CREATE TABLE IF NOT EXISTS invoice_scan_state (
user_hash TEXT NOT NULL,
source TEXT NOT NULL,
last_scan_at INTEGER NOT NULL,
PRIMARY KEY (user_hash, source)
);
CREATE INDEX IF NOT EXISTS invoice_scan_state_retention ON invoice_scan_state(last_scan_at);
CREATE TABLE IF NOT EXISTS invoice_reservations (
user_hash TEXT NOT NULL,
invoice_hash TEXT NOT NULL,
reservation_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
PRIMARY KEY (user_hash, invoice_hash)
);
CREATE INDEX IF NOT EXISTS invoice_reservations_expiry ON invoice_reservations(expires_at);
`);
this.hmacKey = createHmac("sha256", secret).update("dingtalk-approval:invoice-dedup:v1").digest("base64url");
this.cleanupExpired();
}
userHash(userId: string): string { return this.hmac(`user\u0000${userId}`); }
fingerprint(evidence: InvoiceEvidence): string {
return this.hmac(`invoice\u0000${evidence.kind}\u0000${evidence.normalized}`);
}
hasIndexedDetail(userHash: string, source: "dingtalk" | "xft", businessNo: string): boolean {
const row = this.db.prepare(`
SELECT detail_indexed_at, extractor_version FROM invoice_indexed_approvals
WHERE user_hash = ? AND source = ? AND business_no = ?
`).get(userHash, source, businessNo) as { detail_indexed_at?: number | null; extractor_version?: number } | undefined;
return typeof row?.detail_indexed_at === "number" && row.extractor_version === EVIDENCE_EXTRACTOR_VERSION;
}
upsertApproval(
userHash: string,
source: "dingtalk" | "xft",
businessNo: string,
status: string,
submittedAt?: string,
detailIndexed = false,
): void {
const submitted = submittedAt ? Date.parse(submittedAt) : Number.NaN;
const now = Date.now();
this.db.prepare(`
INSERT INTO invoice_indexed_approvals
(user_hash, source, business_no, status, submitted_at, detail_indexed_at, extractor_version, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_hash, source, business_no) DO UPDATE SET
status = excluded.status,
submitted_at = COALESCE(excluded.submitted_at, submitted_at),
detail_indexed_at = CASE
WHEN excluded.detail_indexed_at IS NOT NULL THEN excluded.detail_indexed_at
ELSE detail_indexed_at
END,
extractor_version = CASE
WHEN excluded.detail_indexed_at IS NOT NULL THEN excluded.extractor_version
ELSE extractor_version
END,
updated_at = excluded.updated_at
`).run(
userHash, source, businessNo, status,
Number.isFinite(submitted) ? submitted : null,
detailIndexed ? now : null,
detailIndexed ? EVIDENCE_EXTRACTOR_VERSION : 0,
now,
);
this.db.prepare(`
UPDATE invoice_fingerprints SET status = ?, submitted_at = COALESCE(?, submitted_at)
WHERE user_hash = ? AND source = ? AND business_no = ?
`).run(status, Number.isFinite(submitted) ? submitted : null, userHash, source, businessNo);
}
indexDetail(
userHash: string,
source: "dingtalk" | "xft",
businessNo: string,
status: string,
submittedAt: string | undefined,
evidence: InvoiceEvidence[],
): void {
this.transaction(() => this.indexDetailRows(userHash, source, businessNo, status, submittedAt, evidence));
}
findMatches(userHash: string, evidence: InvoiceEvidence[]): StoredMatch[] {
const hashes = [...new Set(evidence.map((item) => this.fingerprint(item)))];
if (hashes.length === 0) return [];
const placeholders = hashes.map(() => "?").join(",");
return this.db.prepare(`
SELECT invoice_hash, match_kind, source, business_no, status, submitted_at
FROM invoice_fingerprints
WHERE user_hash = ? AND invoice_hash IN (${placeholders})
ORDER BY submitted_at DESC, indexed_at DESC
`).all(userHash, ...hashes) as unknown as StoredMatch[];
}
reserve(userHash: string, evidence: InvoiceEvidence[], reservationId: string, expiresAt: number): boolean {
const hashes = [...new Set(evidence.map((item) => this.fingerprint(item)))];
if (hashes.length === 0) return true;
return this.transaction(() => {
this.db.prepare("DELETE FROM invoice_reservations WHERE expires_at < ?").run(Date.now());
const lookup = this.db.prepare(`
SELECT reservation_id FROM invoice_reservations WHERE user_hash = ? AND invoice_hash = ?
`);
if (hashes.some((hash) => lookup.get(userHash, hash) !== undefined)) return false;
const insert = this.db.prepare(`
INSERT INTO invoice_reservations(user_hash, invoice_hash, reservation_id, expires_at)
VALUES (?, ?, ?, ?)
`);
for (const hash of hashes) insert.run(userHash, hash, reservationId, expiresAt);
return true;
});
}
ownsReservation(userHash: string, reservationId: string): boolean {
const row = this.db.prepare(`
SELECT 1 AS found FROM invoice_reservations
WHERE user_hash = ? AND reservation_id = ? AND expires_at >= ? LIMIT 1
`).get(userHash, reservationId, Date.now()) as { found?: number } | undefined;
return row?.found === 1;
}
commitReservation(
userHash: string,
reservationId: string,
evidence: InvoiceEvidence[],
source: "dingtalk" | "xft",
businessNo: string,
status: string,
submittedAt?: string,
): void {
this.transaction(() => {
if (!this.ownsReservation(userHash, reservationId)) throw new Error("invoice reservation expired");
this.indexDetailRows(userHash, source, businessNo, status, submittedAt, evidence);
this.db.prepare(`
DELETE FROM invoice_reservations WHERE user_hash = ? AND reservation_id = ?
`).run(userHash, reservationId);
});
}
setScanTime(userHash: string, source: "dingtalk" | "xft", time = Date.now()): void {
this.db.prepare(`
INSERT INTO invoice_scan_state(user_hash, source, last_scan_at) VALUES (?, ?, ?)
ON CONFLICT(user_hash, source) DO UPDATE SET last_scan_at = excluded.last_scan_at
`).run(userHash, source, time);
}
cleanupExpired(now = Date.now(), retentionDays = FINGERPRINT_RETENTION_DAYS): void {
const cutoff = now - Math.max(HISTORY_DAYS, retentionDays) * 86_400_000;
this.transaction(() => {
this.db.prepare("DELETE FROM invoice_reservations WHERE expires_at < ?").run(now);
this.db.prepare(`
DELETE FROM invoice_indexed_approvals
WHERE COALESCE(submitted_at, updated_at) < ?
`).run(cutoff);
this.db.prepare("DELETE FROM invoice_scan_state WHERE last_scan_at < ?").run(cutoff);
});
}
private hmac(value: string): string {
return createHmac("sha256", this.hmacKey).update(value).digest("base64url");
}
private indexDetailRows(
userHash: string,
source: "dingtalk" | "xft",
businessNo: string,
status: string,
submittedAt: string | undefined,
evidence: InvoiceEvidence[],
): void {
this.upsertApproval(userHash, source, businessNo, status, submittedAt, true);
const submitted = submittedAt ? Date.parse(submittedAt) : Number.NaN;
const insert = this.db.prepare(`
INSERT INTO invoice_fingerprints
(user_hash, invoice_hash, match_kind, source, business_no, status, submitted_at, indexed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_hash, invoice_hash, source, business_no) DO UPDATE SET
status = excluded.status,
submitted_at = COALESCE(excluded.submitted_at, submitted_at),
indexed_at = excluded.indexed_at
`);
for (const item of evidence) {
insert.run(
userHash, this.fingerprint(item), item.kind, source, businessNo, status,
Number.isFinite(submitted) ? submitted : null, Date.now(),
);
}
}
private transaction<T>(operation: () => T): T {
this.db.exec("BEGIN IMMEDIATE");
try {
const result = operation();
this.db.exec("COMMIT");
return result;
} catch (error) {
this.db.exec("ROLLBACK");
throw error;
}
}
}
export class InvoiceDeduplicationService {
private readonly synchronizations = new Map<string, Promise<SyncResult>>();
constructor(
private readonly config: AppConfig,
private readonly store: InvoiceDedupStore,
private readonly dingTalk: ApprovalService,
private readonly xft?: XftApprovalProvider,
) {}
async prepare(
user: AuthenticatedDingTalkUser,
values: Record<string, unknown>,
allowDuplicateInvoices: boolean,
expiresAt: number,
invoiceEvidenceExpected = false,
): Promise<PreparedDeduplication> {
const evidence = extractInvoiceEvidence(values);
if (evidence.length === 0) {
if (!invoiceEvidenceExpected) return { duplicateCheck: noInvoiceCheck() };
return {
duplicateCheck: {
...noInvoiceCheck(),
status: allowDuplicateInvoices ? "proceeding_after_confirmation" : "check_unavailable",
coverageComplete: false,
requiresUserConfirmation: !allowDuplicateInvoices,
message: allowDuplicateInvoices
? "该报销表单未识别到可查重的发票唯一标识或完整费用组合;用户已明确知晓无法完成发票级查重并仍要继续。"
: "该报销表单未识别到可查重的发票唯一标识或完整费用组合,无法确认是否重复;请补充发票信息,或向用户明确说明后由用户决定是否继续。",
},
};
}
const userHash = this.store.userHash(user.userId);
const sync = await this.synchronize(user, userHash);
const byHash = new Map(evidence.map((item) => [this.store.fingerprint(item), item]));
const matches = deduplicatePublicMatches(this.store.findMatches(userHash, evidence).map((match) => publicMatch(match, byHash)));
const historicalRisk = matches.length > 0;
const requiresConfirmation = historicalRisk || !sync.complete;
const sourceMessage = !sync.complete
? "发票查重未能完整覆盖本人近30天的钉钉与薪福通历史,不能视为无重复。"
: historicalRisk
? "发现本人历史审批中存在相同或疑似相同发票/费用。"
: "未在本人近30天的钉钉与薪福通已提交审批中发现重复发票。";
const duplicateCheck: InvoiceDuplicateCheck = {
status: requiresConfirmation
? allowDuplicateInvoices ? "proceeding_after_confirmation" : sync.complete ? "confirmation_required" : "check_unavailable"
: "clear",
checkedSources: sync.checkedSources,
coverageComplete: sync.complete,
invoiceEvidence: evidence.map(({ kind, strength, maskedDisplay }) => ({ kind, strength, maskedDisplay })),
matches,
requiresUserConfirmation: requiresConfirmation && !allowDuplicateInvoices,
message: requiresConfirmation && allowDuplicateInvoices
? `${sourceMessage} 用户已明确确认仍要继续,本次确认引用将绑定该决定。`
: sourceMessage,
sourceStatus: sync.sourceStatus,
};
if (requiresConfirmation && !allowDuplicateInvoices) return { duplicateCheck };
const reservationId = randomBytes(18).toString("base64url");
if (!this.store.reserve(userHash, evidence, reservationId, expiresAt)) {
return {
duplicateCheck: {
...duplicateCheck,
status: "confirmation_required",
requiresUserConfirmation: true,
message: "相同发票正在另一个尚未过期的提单确认中。为避免并发重复提交,本次不会生成提交确认;请先处理或等待原确认过期。",
},
};
}
return { duplicateCheck, reservationId };
}
assertReservation(user: AuthenticatedDingTalkUser, reservationId: string | undefined): void {
if (!reservationId || !this.store.ownsReservation(this.store.userHash(user.userId), reservationId)) {
throw new ApprovalSafetyError("发票查重预留已过期或不属于当前用户,请重新准备并确认提单");
}
}
async recordSubmission(
user: AuthenticatedDingTalkUser,
reservationId: string | undefined,
values: Record<string, unknown>,
source: "dingtalk" | "xft",
businessNo: string,
status: string,
): Promise<"indexed" | "not_applicable" | "index_failed"> {
const evidence = extractInvoiceEvidence(values);
if (evidence.length === 0 || !reservationId) return "not_applicable";
try {
this.store.commitReservation(
this.store.userHash(user.userId), reservationId, evidence, source, businessNo,
normalizeApprovalStatus(status), new Date().toISOString(),
);
return "indexed";
} catch {
// The upstream write already happened. Never turn an index maintenance
// failure into a submission failure that could invite a dangerous retry.
return "index_failed";
}
}
private async synchronize(user: AuthenticatedDingTalkUser, userHash: string): Promise<SyncResult> {
const existing = this.synchronizations.get(userHash);
if (existing) return existing;
const task = this.runSynchronization(user, userHash).finally(() => this.synchronizations.delete(userHash));
this.synchronizations.set(userHash, task);
return task;
}
private async runSynchronization(user: AuthenticatedDingTalkUser, userHash: string): Promise<SyncResult> {
const now = Date.now();
this.store.cleanupExpired(now);
const range: ApprovalHistoryRange = {
startTime: now - Math.min(HISTORY_DAYS, this.config.officialLookbackDays) * 86_400_000,
endTime: now,
};
const sourceStatus: Record<string, Record<string, unknown>> = {};
const checkedSources: Array<"dingtalk" | "xft"> = [];
let complete = true;
const sources: Array<Promise<void>> = [];
sources.push(this.dingTalk.listMyHistory(user.userId, "submitted", HISTORY_LIMIT, range).then(async (result) => {
const records = result.items.map((item) => dingTalkHistoryRecord(user, this.dingTalk, item));
const indexed = await this.indexRecords(userHash, records);
const truncated = result.diagnostics?.truncated === true;
sourceStatus.dingtalk = { status: indexed.failed === 0 && !truncated ? "ok" : "partial", count: records.length, ...indexed, ...(truncated ? { truncated: true } : {}) };
checkedSources.push("dingtalk");
if (indexed.failed > 0 || truncated) complete = false;
this.store.setScanTime(userHash, "dingtalk");
}).catch((error) => {
complete = false;
sourceStatus.dingtalk = { status: "unavailable", message: errorMessage(error) };
}));
if (!this.xft) {
complete = false;
sourceStatus.xft = { status: "disabled" };
} else {
sources.push(this.xft.listMyHistory(user, "submitted", HISTORY_LIMIT, "", range).then(async (result) => {
const records = result.items.map((item) => xftHistoryRecord(user, this.xft!, item));
const indexed = await this.indexRecords(userHash, records);
const truncated = result.diagnostics?.truncated === true;
sourceStatus.xft = { status: indexed.failed === 0 && !truncated ? "ok" : "partial", count: records.length, ...indexed, ...(truncated ? { truncated: true } : {}) };
checkedSources.push("xft");
if (indexed.failed > 0 || truncated) complete = false;
this.store.setScanTime(userHash, "xft");
}).catch((error) => {
complete = false;
sourceStatus.xft = { status: "unavailable", message: errorMessage(error) };
}));
}
await Promise.all(sources);
return { checkedSources: checkedSources.sort(), complete, sourceStatus };
}
private async indexRecords(userHash: string, records: HistoryRecord[]): Promise<{ indexed: number; cached: number; failed: number }> {
let indexed = 0;
let cached = 0;
let failed = 0;
const pending: HistoryRecord[] = [];
for (const record of records) {
this.store.upsertApproval(userHash, record.source, record.businessNo, record.status, record.submittedAt);
if (this.store.hasIndexedDetail(userHash, record.source, record.businessNo)) cached += 1;
else pending.push(record);
}
await mapConcurrent(pending, DETAIL_CONCURRENCY, async (record) => {
try {
const detail = await record.detail();
this.store.indexDetail(
userHash, record.source, record.businessNo, record.status, record.submittedAt,
extractInvoiceEvidence(detail),
);
indexed += 1;
} catch {
failed += 1;
}
});
return { indexed, cached, failed };
}
}
export function invoiceDedupDatabasePath(config: AppConfig): string {
const authPath = config.remoteAuth?.databasePath ?? ".runtime/auth.sqlite";
return `${authPath}.invoice-dedup.sqlite`;
}
export function extractInvoiceEvidence(root: unknown): InvoiceEvidence[] {
const found = new Map<string, InvoiceEvidence>();
const labeledCodes: string[] = [];
const labeledNumbers: string[] = [];
const seen = new WeakSet<object>();
let visited = 0;
const add = (item: InvoiceEvidence | undefined) => {
if (!item || found.size >= MAX_EVIDENCE) return;
found.set(`${item.kind}\u0000${item.normalized}`, item);
};
const walk = (value: unknown, path: string[], depth: number): void => {
if (depth > 18 || visited >= MAX_VISITED_NODES || found.size >= MAX_EVIDENCE) return;
if (typeof value === "string") {
const parsed = parseJson(value);
if (parsed !== undefined) walk(parsed, path, depth + 1);
return;
}
if (!value || typeof value !== "object" || seen.has(value)) return;
seen.add(value);
visited += 1;
if (Array.isArray(value)) {
for (const child of value) walk(child, path, depth + 1);
return;
}
const record = value as Record<string, unknown>;
const context = path.join("/").toLocaleLowerCase();
const label = fieldLabel(record);
if (label && !sensitiveLabel(label)) {
const labeledValue = scalarString(fieldValue(record));
const normalizedLabel = normalizeLabel(label);
if (/^(?:发票代码|invoicecode)$/.test(normalizedLabel) && labeledValue) labeledCodes.push(labeledValue);
else if (/^(?:发票号码|发票号|票据号码|票据号|invoicenumber|invoiceno)$/.test(normalizedLabel) && labeledValue) labeledNumbers.push(labeledValue);
add(labeledEvidence(label, fieldValue(record)));
}
const code = directString(record, INVOICE_CODE_KEYS);
const number = directString(record, INVOICE_NUMBER_KEYS);
if (code && number) add(codeNumberEvidence(code, number));
const digital = directString(record, DIGITAL_INVOICE_KEYS);
if (digital) add(digitalEvidence(digital));
if (number && looksInvoiceContext(context, record)) add(numberEvidence(number));
for (const [key, child] of Object.entries(record)) {
const normalizedKey = normalizeKey(key);
const childContext = `${context}/${normalizedKey}`;
if (sensitiveLabel(key)) continue;
if (DIGITAL_INVOICE_KEYS.has(normalizedKey)) add(digitalEvidence(scalarString(child)));
else if (INVOICE_NUMBER_KEYS.has(normalizedKey) && looksInvoiceContext(childContext, record)) add(numberEvidence(scalarString(child)));
else if (HASH_KEYS.has(normalizedKey) && looksInvoiceContext(childContext, record)) add(hashEvidence(scalarString(child)));
walk(child, [...path, key], depth + 1);
}
add(expenseSignatureEvidence(record));
};
walk(root, [], 0);
if (labeledCodes.length === labeledNumbers.length) {
for (let index = 0; index < labeledCodes.length; index += 1) {
add(codeNumberEvidence(labeledCodes[index], labeledNumbers[index]));
}
}
return [...found.values()];
}
function dingTalkHistoryRecord(
user: AuthenticatedDingTalkUser,
service: ApprovalService,
item: ApprovalHistoryItem,
): HistoryRecord {
return {
source: "dingtalk",
businessNo: item.processInstanceId,
status: normalizeApprovalStatus(item.status, item.result),
...(item.submittedAt ? { submittedAt: item.submittedAt } : {}),
detail: async () => cachedOwnedDingTalkDetail(user, item)
?? service.getHistoryDetail(user.userId, "submitted", item.processInstanceId, item.taskId),
};
}
function cachedOwnedDingTalkDetail(user: AuthenticatedDingTalkUser, item: ApprovalHistoryItem): unknown | undefined {
if (item.enrichmentStatus !== "complete" || !isRecord(item.raw)) return undefined;
for (const key of ["originatorUserId", "originatorUserid", "originatorId", "creatorUserId"]) {
const owner = item.raw[key];
if ((typeof owner === "string" || typeof owner === "number") && String(owner) === user.userId) return item.raw;
}
return undefined;
}
function xftHistoryRecord(
user: AuthenticatedDingTalkUser,
service: XftApprovalProvider,
item: XftHistoryItem,
): HistoryRecord {
return {
source: "xft",
businessNo: item.billId,
status: normalizeApprovalStatus(item.status, item.statusName),
...(item.applyTime ? { submittedAt: item.applyTime } : {}),
detail: () => service.getHistoryDetail(user, "submitted", item.billId),
};
}
function publicMatch(match: StoredMatch, evidence: Map<string, InvoiceEvidence>): InvoiceDuplicateMatch {
const item = evidence.get(match.invoice_hash);
const strength = item?.strength ?? strengthForKind(match.match_kind);
return {
source: match.source,
businessNo: match.business_no,
...(typeof match.submitted_at === "number" ? { submittedAt: new Date(match.submitted_at).toISOString() } : {}),
status: match.status,
matchKind: match.match_kind,
matchStrength: strength,
invoice: item?.maskedDisplay ?? "已脱敏发票标识",
disposition: strength === "suspected" || !ACTIVE_STATUSES.has(match.status) ? "warning" : "blocking",
};
}
function deduplicatePublicMatches(matches: InvoiceDuplicateMatch[]): InvoiceDuplicateMatch[] {
const unique = new Map<string, InvoiceDuplicateMatch>();
const rank = { suspected: 0, auxiliary: 1, exact: 2 } as const;
for (const match of matches) {
const key = `${match.source}\u0000${match.businessNo}`;
const current = unique.get(key);
if (!current || rank[match.matchStrength] > rank[current.matchStrength]) unique.set(key, match);
}
return [...unique.values()];
}
function noInvoiceCheck(): InvoiceDuplicateCheck {
return {
status: "not_applicable",
checkedSources: [],
coverageComplete: true,
invoiceEvidence: [],
matches: [],
requiresUserConfirmation: false,
message: "本次表单未识别到发票唯一标识、附件哈希或可用的费用组合,未执行发票级查重。",
sourceStatus: {},
};
}
function codeNumberEvidence(codeValue: string | undefined, numberValue: string | undefined): InvoiceEvidence | undefined {
const code = normalizeIdentifier(codeValue);
const number = normalizeIdentifier(numberValue);
if (!validInvoiceIdentifier(code, 6) || !validInvoiceIdentifier(number, 6)) return undefined;
return { kind: "invoice_code_number", strength: "exact", normalized: `${code}\u0000${number}`, maskedDisplay: `${mask(code)}/${mask(number)}` };
}
function digitalEvidence(value: string | undefined): InvoiceEvidence | undefined {
const normalized = normalizeIdentifier(value);
if (!validInvoiceIdentifier(normalized, 12)) return undefined;
return { kind: "digital_invoice_number", strength: "exact", normalized, maskedDisplay: mask(normalized) };
}
function numberEvidence(value: string | undefined): InvoiceEvidence | undefined {
const normalized = normalizeIdentifier(value);
if (!validInvoiceIdentifier(normalized, 6)) return undefined;
return { kind: "invoice_number", strength: "auxiliary", normalized, maskedDisplay: mask(normalized) };
}
function hashEvidence(value: string | undefined): InvoiceEvidence | undefined {
const normalized = value?.trim().toLocaleLowerCase() ?? "";
if (!/^[0-9a-f]{32,128}$/.test(normalized)) return undefined;
return { kind: "attachment_hash", strength: "auxiliary", normalized, maskedDisplay: `附件哈希…${normalized.slice(-6)}` };
}
function labeledEvidence(labelValue: string, value: unknown): InvoiceEvidence | undefined {
const label = normalizeLabel(labelValue);
const valueString = scalarString(value);
if (/^(?:发票代码|invoicecode)$/.test(label)) return undefined;
if (/(?:数电票|全电票|电子发票).*(?:唯一|号码|编号)|发票唯一(?:号码|编号)|digitalinvoice/.test(label)) return digitalEvidence(valueString);
if (/^(?:发票号码|发票号|票据号码|票据号|invoicenumber|invoiceno)$/.test(label)) return numberEvidence(valueString);
if (/(?:发票|票据).*(?:哈希|hash|摘要)/.test(label)) return hashEvidence(valueString);
return undefined;
}
function expenseSignatureEvidence(record: Record<string, unknown>): InvoiceEvidence | undefined {
const amount = directValue(record, AMOUNT_KEYS);
const date = directValue(record, DATE_KEYS);
const merchant = directValue(record, MERCHANT_KEYS);
const normalizedAmount = normalizeAmount(amount);
const normalizedDate = normalizeDate(date);
const normalizedMerchant = normalizeMerchant(merchant);
if (!normalizedAmount || !normalizedDate || !normalizedMerchant) return undefined;
return {
kind: "expense_signature",
strength: "suspected",
normalized: `${normalizedAmount}\u0000${normalizedDate}\u0000${normalizedMerchant}`,
maskedDisplay: `费用组合 ${normalizedDate} / ¥${normalizedAmount} / ${maskMerchant(normalizedMerchant)}`,
};
}
function normalizeApprovalStatus(status?: string, result?: string): string {
const value = `${result ?? ""} ${status ?? ""}`.trim().toLocaleLowerCase();
if (/撤回|撤销|终止|取消|terminate|withdraw|revoke|cancel/.test(value)) return "revoked";
if (/作废|void|deleted/.test(value)) return "void";
if (/拒绝|驳回|不同意|reject|refuse|disagree/.test(value)) return "rejected";
if (/待审批|审批中|进行中|running|pending|wait|apprv_pend/.test(value) || /^(?:0|1)$/.test(value)) return "pending";
if (/同意|通过|完成|agree|approve|approved|pass|completed|finish|apprv/.test(value) || value === "2") return "approved";
if (/uncertain/.test(value)) return "outcome_uncertain";
return "unknown";
}
function strengthForKind(kind: InvoiceEvidenceKind): InvoiceEvidence["strength"] {
if (kind === "invoice_code_number" || kind === "digital_invoice_number") return "exact";
if (kind === "expense_signature") return "suspected";
return "auxiliary";
}
const INVOICE_CODE_KEYS = new Set(["invoicecode", "invcode", "fapiaocode"]);
const INVOICE_NUMBER_KEYS = new Set(["invoicenumber", "invoiceno", "invnumber", "invno", "billnumber", "ticketnumber", "fapiaonumber"]);
const DIGITAL_INVOICE_KEYS = new Set(["digitalinvoicenumber", "electronicinvoicenumber", "allelectronicinvoicenumber", "invoiceuniquenumber", "invoiceuniqueid", "digitalinvoiceno"]);
const HASH_KEYS = new Set(["attachmenthash", "filehash", "sha256", "sha512", "md5", "invoicehash"]);
const AMOUNT_KEYS = new Set(["amount", "invoiceamount", "totalamount", "reimamount", "reimamt", "expenseamount", "taxinclusiveamount"]);
const DATE_KEYS = new Set(["invoicedate", "occurredon", "expensedate", "wrtim", "transactiondate", "date"]);
const MERCHANT_KEYS = new Set(["merchant", "merchantname", "merchantnam", "sellername", "payee", "vendor", "suppliername"]);
function directString(record: Record<string, unknown>, keys: Set<string>): string | undefined {
return scalarString(directValue(record, keys));
}
function directValue(record: Record<string, unknown>, keys: Set<string>): unknown {
for (const [key, value] of Object.entries(record)) if (keys.has(normalizeKey(key))) return unwrapScalar(value);
return undefined;
}
function unwrapScalar(value: unknown): unknown {
if (!isRecord(value)) return value;
for (const key of ["value", "fieldValue", "content", "displayValue"]) if (key in value) return value[key];
return value;
}
function scalarString(value: unknown): string | undefined {
const unwrapped = unwrapScalar(value);
return typeof unwrapped === "string" || typeof unwrapped === "number" ? String(unwrapped).trim() || undefined : undefined;
}
function fieldLabel(record: Record<string, unknown>): string | undefined {
for (const key of ["label", "name", "fieldName", "title", "bizAlias"]) {
if (typeof record[key] === "string" && record[key].trim()) return record[key].trim();
}
return undefined;
}
function fieldValue(record: Record<string, unknown>): unknown {
for (const key of ["value", "fieldValue", "content", "displayValue", "extValue"]) if (key in record) return record[key];
return undefined;
}
function looksInvoiceContext(context: string, record: Record<string, unknown>): boolean {
return /invoice|发票|fapiao|certificate|票据/.test(context)
|| Object.keys(record).some((key) => /invoice|发票|fapiao|certificate|票据/i.test(key));
}
function validInvoiceIdentifier(value: string, minimum: number): boolean {
if (value.length < minimum || value.length > 40 || !/^[0-9A-Z]+$/.test(value)) return false;
return !/^(?:0+|1+)$/.test(value);
}
function normalizeIdentifier(value: string | undefined): string {
return (value ?? "").normalize("NFKC").toLocaleUpperCase().replace(/[^0-9A-Z]/g, "");
}
function normalizeKey(value: string): string { return value.normalize("NFKC").toLocaleLowerCase().replace(/[^a-z0-9]/g, ""); }
function normalizeLabel(value: string): string { return value.normalize("NFKC").toLocaleLowerCase().replace(/[\s_\-::()()]/g, ""); }
function normalizeAmount(value: unknown): string | undefined {
const text = scalarString(value)?.replace(/[,,¥¥\s]/g, "");
if (!text || !/^-?\d+(?:\.\d{1,4})?$/.test(text)) return undefined;
const number = Number(text);
return Number.isFinite(number) && number > 0 ? number.toFixed(2) : undefined;
}
function normalizeDate(value: unknown): string | undefined {
const text = scalarString(value);
if (!text) return undefined;
const match = text.match(/(20\d{2})[-/.年]?(\d{1,2})[-/.月]?(\d{1,2})/);
if (match) return `${match[1]}-${match[2]!.padStart(2, "0")}-${match[3]!.padStart(2, "0")}`;
const timestamp = Number(text);
if (/^\d{10,13}$/.test(text) && Number.isFinite(timestamp)) {
const date = new Date(text.length === 10 ? timestamp * 1000 : timestamp);
if (Number.isFinite(date.getTime())) return date.toISOString().slice(0, 10);
}
return undefined;
}
function normalizeMerchant(value: unknown): string | undefined {
const text = scalarString(value)?.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, "").slice(0, 120);
return text && text.length >= 2 ? text : undefined;
}
function mask(value: string): string { return value.length <= 4 ? "****" : `****${value.slice(-4)}`; }
function maskMerchant(value: string): string { return value.length <= 2 ? "**" : `${value.slice(0, 1)}***${value.slice(-1)}`; }
function parseJson(value: string): unknown {
const trimmed = value.trim();
if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) return undefined;
if (trimmed.length > 500_000) return undefined;
try { return JSON.parse(trimmed); } catch { return undefined; }
}
async function mapConcurrent<T>(items: T[], concurrency: number, operation: (item: T) => Promise<void>): Promise<void> {
let cursor = 0;
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (cursor < items.length) {
const item = items[cursor++];
if (item !== undefined) await operation(item);
}
}));
}
+27
View File
@@ -0,0 +1,27 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
export function objectValue(value: unknown): Record<string, unknown> | undefined {
return isRecord(value) ? value : undefined;
}
export function stringValue(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
/** Like stringValue, but also stringifies finite numbers (XFT payloads mix numeric and string ids). */
export function numberishStringValue(value: unknown): string | undefined {
return typeof value === "string" ? value : typeof value === "number" && Number.isFinite(value) ? String(value) : undefined;
}
export function numericValue(value: unknown): number | undefined {
const parsed = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : NaN;
return Number.isFinite(parsed) ? parsed : undefined;
}
export function filterQuery<T>(items: T[], query: string): T[] {
const normalized = query.trim().toLowerCase();
if (!normalized) return items;
return items.filter((item) => JSON.stringify(item).toLowerCase().includes(normalized));
}
+684
View File
@@ -0,0 +1,684 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
import { chmodSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
import type { Response } from "express";
import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js";
import {
InvalidClientMetadataError,
InvalidGrantError,
InvalidRequestError,
} from "@modelcontextprotocol/sdk/server/auth/errors.js";
import type { AuthorizationParams, OAuthServerProvider } from "@modelcontextprotocol/sdk/server/auth/provider.js";
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import type {
OAuthClientInformationFull,
OAuthTokenRevocationRequest,
OAuthTokens,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import type { AppConfig } from "./config.js";
import type { AuthenticatedDingTalkUser } from "./xft.js";
export const DINGTALK_OAUTH_AUTHORIZE_ORIGIN = "https://login.dingtalk.com";
type JsonRow = Record<string, unknown>;
interface StoredAuthorization {
flow: "authorization_code";
client: OAuthClientInformationFull;
params: AuthorizationParams;
consentApproved: boolean;
expiresAt: number;
}
interface StoredDeviceLogin {
flow: "device";
deviceCodeHash: string;
clientId: string;
scopes: string[];
userCode: string;
consentApproved: boolean;
expiresAt: number;
}
interface StoredDeviceCode {
clientId: string;
scopes: string[];
resource?: string;
user?: AuthenticatedDingTalkUser;
status: "pending" | "authorized";
expiresAt: number;
}
interface StoredCode extends StoredAuthorization {
user: AuthenticatedDingTalkUser;
}
interface StoredToken {
clientId: string;
user: AuthenticatedDingTalkUser;
scopes: string[];
resource?: string;
expiresAt: number;
}
interface DingTalkTokenResponse {
accessToken?: string;
refreshToken?: string;
expireIn?: number;
expiresIn?: number;
corpId?: string;
}
/** Persistent, single-enterprise OAuth provider for remote MCP clients. */
export class DingTalkMcpOAuthProvider implements OAuthServerProvider {
readonly clientsStore: OAuthRegisteredClientsStore;
private readonly db: DatabaseSync;
private readonly auth: NonNullable<AppConfig["remoteAuth"]>;
private readonly encryptionKey: Buffer;
private appToken?: { value: string; expiresAt: number };
private readonly userRefreshes = new Map<string, Promise<string>>();
private readonly cleanupTimer: NodeJS.Timeout;
constructor(private readonly config: AppConfig, private readonly fetchFn: typeof fetch = fetch) {
if (!config.remoteAuth) throw new Error("远程 OAuth 未启用");
if (!config.corpId) throw new Error("远程 OAuth 必须配置钉钉 CorpId");
this.auth = config.remoteAuth;
const databasePath = resolve(this.auth.databasePath);
mkdirSync(dirname(databasePath), { recursive: true, mode: 0o700 });
this.db = new DatabaseSync(databasePath);
chmodSync(databasePath, 0o600);
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;");
this.db.exec(`
CREATE TABLE IF NOT EXISTS oauth_clients (
client_id TEXT PRIMARY KEY,
metadata TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS login_requests (
login_state TEXT PRIMARY KEY,
payload TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS authorization_codes (
code_hash TEXT PRIMARY KEY,
payload TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS access_tokens (
token_hash TEXT PRIMARY KEY,
payload TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
token_hash TEXT PRIMARY KEY,
payload TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS identities (
user_id TEXT PRIMARY KEY,
name TEXT,
token_cipher TEXT NOT NULL,
expires_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS device_codes (
device_code_hash TEXT PRIMARY KEY,
user_code TEXT UNIQUE NOT NULL,
payload TEXT NOT NULL,
expires_at INTEGER NOT NULL,
last_poll_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS audit_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
action TEXT NOT NULL,
reference_hash TEXT,
outcome TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS audit_events_created_at ON audit_events(created_at);
`);
this.ensureOAuthClientLastUsedColumn();
this.encryptionKey = createHash("sha256").update(this.auth.sessionSecret).digest();
this.clientsStore = {
getClient: async (clientId) => this.getClient(clientId),
registerClient: async (client) => this.registerClient(client as OAuthClientInformationFull),
};
this.cleanup();
this.cleanupTimer = setInterval(() => this.cleanup(), 60 * 60_000);
this.cleanupTimer.unref();
}
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
if (!client.redirect_uris.includes(params.redirectUri) && !loopbackRedirectMatches(client.redirect_uris, params.redirectUri)) {
throw new InvalidRequestError("Unregistered redirect_uri");
}
const loginState = randomToken(32);
const payload: StoredAuthorization = {
flow: "authorization_code", client, params, consentApproved: false,
expiresAt: Date.now() + 10 * 60_000,
};
this.db.prepare("INSERT INTO login_requests(login_state,payload,expires_at) VALUES(?,?,?)")
.run(loginState, serialize(payload), payload.expiresAt);
const consent = new URL("/oauth/consent", this.auth.issuerUrl);
consent.searchParams.set("login_state", loginState);
res.redirect(302, consent.toString());
}
consentDetails(loginState: string) {
const pending = this.readPendingLogin(loginState);
if (pending.flow === "device") {
const client = this.getClient(pending.clientId);
if (!client) throw new InvalidGrantError("OAuth 客户端已失效");
return {
clientName: client.client_name ?? "未命名 OAuth 客户端",
scopes: pending.scopes,
userCode: pending.userCode,
};
}
return {
clientName: pending.client.client_name ?? "未命名 OAuth 客户端",
scopes: pending.params.scopes ?? [],
redirectOrigin: new URL(pending.params.redirectUri).origin,
};
}
approveConsent(loginState: string): string {
const pending = this.readPendingLogin(loginState);
const approved = { ...pending, consentApproved: true };
this.db.prepare("UPDATE login_requests SET payload=? WHERE login_state=?")
.run(serialize(approved), loginState);
return this.dingTalkAuthorizeUrl(loginState);
}
async completeDingTalkLogin(code: string, loginState: string): Promise<string> {
const row = this.db.prepare("SELECT payload,expires_at FROM login_requests WHERE login_state=?").get(loginState) as JsonRow | undefined;
this.db.prepare("DELETE FROM login_requests WHERE login_state=?").run(loginState);
if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("登录请求无效或已过期");
const pending = deserialize<StoredAuthorization | StoredDeviceLogin>(String(row.payload));
if (!pending.consentApproved) throw new InvalidGrantError("用户尚未确认 OAuth 客户端授权");
const upstream = await this.exchangeDingTalkCode(code);
const user = await this.resolveDingTalkIdentity(upstream);
this.saveIdentity(user, upstream);
if (pending.flow === "device") {
const deviceRow = this.db.prepare("SELECT payload,expires_at FROM device_codes WHERE device_code_hash=?")
.get(pending.deviceCodeHash) as JsonRow | undefined;
if (!deviceRow || Number(deviceRow.expires_at) < Date.now()) throw new InvalidGrantError("设备授权请求已过期");
const device = deserialize<StoredDeviceCode>(String(deviceRow.payload));
const authorized: StoredDeviceCode = { ...device, status: "authorized", user };
this.db.prepare("UPDATE device_codes SET payload=? WHERE device_code_hash=?")
.run(serialize(authorized), pending.deviceCodeHash);
return new URL("/oauth/device/success", this.auth.issuerUrl).toString();
}
const authorizationCode = randomToken(32);
const stored: StoredCode = { ...pending, user, expiresAt: Date.now() + 5 * 60_000 };
this.db.prepare("INSERT INTO authorization_codes(code_hash,payload,expires_at) VALUES(?,?,?)")
.run(hashToken(authorizationCode), serialize(stored), stored.expiresAt);
const redirect = new URL(pending.params.redirectUri);
redirect.searchParams.set("code", authorizationCode);
if (pending.params.state) redirect.searchParams.set("state", pending.params.state);
return redirect.toString();
}
startDeviceAuthorization(clientId: string, scopes: string[], resource?: string) {
if (!this.getClient(clientId)) throw new InvalidGrantError("未知 OAuth 客户端");
this.db.prepare("DELETE FROM device_codes WHERE expires_at < ?").run(Date.now());
const pending = this.db.prepare("SELECT COUNT(*) AS count FROM device_codes WHERE payload LIKE ?")
.get(`%\"clientId\":\"${clientId.replace(/[\\%_]/g, "")}\"%`) as JsonRow;
if (Number(pending.count) >= 10) throw new InvalidRequestError("该客户端的待登录请求过多,请稍后重试");
const total = this.db.prepare("SELECT COUNT(*) AS count FROM device_codes").get() as JsonRow;
if (Number(total.count) >= 1000) throw new InvalidRequestError("登录服务繁忙,请稍后重试");
const deviceCode = randomToken(32);
const userCode = readableUserCode();
const expiresAt = Date.now() + 10 * 60_000;
const stored: StoredDeviceCode = { clientId, scopes, ...(resource ? { resource } : {}), status: "pending", expiresAt };
this.db.prepare("INSERT INTO device_codes(device_code_hash,user_code,payload,expires_at,last_poll_at) VALUES(?,?,?,?,0)")
.run(hashToken(deviceCode), userCode, serialize(stored), expiresAt);
const verificationUri = new URL("/oauth/device", this.auth.issuerUrl);
const complete = new URL(verificationUri);
complete.searchParams.set("user_code", userCode);
return {
device_code: deviceCode,
user_code: userCode,
verification_uri: verificationUri.toString(),
verification_uri_complete: complete.toString(),
expires_in: 600,
interval: 5,
};
}
beginDeviceLogin(userCode: string): string {
const row = this.db.prepare("SELECT device_code_hash,payload,expires_at FROM device_codes WHERE user_code=?")
.get(normalizeUserCode(userCode)) as JsonRow | undefined;
if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("设备授权码无效或已过期");
const device = deserialize<StoredDeviceCode>(String(row.payload));
if (device.status !== "pending") return new URL("/oauth/device/success", this.auth.issuerUrl).toString();
const loginState = randomToken(32);
const pending: StoredDeviceLogin = {
flow: "device", deviceCodeHash: String(row.device_code_hash), clientId: device.clientId,
scopes: device.scopes, userCode: normalizeUserCode(userCode), consentApproved: false,
expiresAt: Number(row.expires_at),
};
this.db.prepare("INSERT INTO login_requests(login_state,payload,expires_at) VALUES(?,?,?)")
.run(loginState, serialize(pending), pending.expiresAt);
const consent = new URL("/oauth/consent", this.auth.issuerUrl);
consent.searchParams.set("login_state", loginState);
return consent.toString();
}
exchangeDeviceCode(clientId: string, deviceCode: string): OAuthTokens | "authorization_pending" | "slow_down" {
const hash = hashToken(deviceCode);
const row = this.db.prepare("SELECT payload,expires_at,last_poll_at FROM device_codes WHERE device_code_hash=?")
.get(hash) as JsonRow | undefined;
if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("设备授权请求无效或已过期");
const stored = deserialize<StoredDeviceCode>(String(row.payload));
if (stored.clientId !== clientId) throw new InvalidGrantError("设备授权请求不属于当前客户端");
const now = Date.now();
if (stored.status === "pending") {
if (Number(row.last_poll_at) > now - 4_000) return "slow_down";
this.db.prepare("UPDATE device_codes SET last_poll_at=? WHERE device_code_hash=?").run(now, hash);
return "authorization_pending";
}
if (!stored.user) throw new InvalidGrantError("设备授权缺少用户身份");
this.db.prepare("DELETE FROM device_codes WHERE device_code_hash=?").run(hash);
return this.issueTokens(stored.clientId, stored.user, stored.scopes, stored.resource);
}
async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string> {
const stored = this.readCode(authorizationCode);
if (stored.client.client_id !== client.client_id) throw new InvalidGrantError("授权码不属于当前客户端");
return stored.params.codeChallenge;
}
async exchangeAuthorizationCode(
client: OAuthClientInformationFull,
authorizationCode: string,
_codeVerifier?: string,
redirectUri?: string,
resource?: URL,
): Promise<OAuthTokens> {
const stored = this.readCode(authorizationCode);
if (stored.client.client_id !== client.client_id) throw new InvalidGrantError("授权码不属于当前客户端");
if (redirectUri && redirectUri !== stored.params.redirectUri) throw new InvalidGrantError("redirect_uri 不匹配");
if (!resourceMatches(stored.params.resource, resource)) throw new InvalidGrantError("resource 不匹配");
this.db.prepare("DELETE FROM authorization_codes WHERE code_hash=?").run(hashToken(authorizationCode));
return this.issueTokens(client.client_id, stored.user, stored.params.scopes ?? [], stored.params.resource?.toString());
}
async exchangeRefreshToken(
client: OAuthClientInformationFull,
refreshToken: string,
scopes?: string[],
resource?: URL,
): Promise<OAuthTokens> {
const hash = hashToken(refreshToken);
const row = this.db.prepare("SELECT payload,expires_at FROM refresh_tokens WHERE token_hash=?").get(hash) as JsonRow | undefined;
if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("Refresh Token 无效或已过期");
const stored = deserialize<StoredToken>(String(row.payload));
if (stored.clientId !== client.client_id) throw new InvalidGrantError("Refresh Token 不属于当前客户端");
if (resource && stored.resource && resource.toString() !== stored.resource) throw new InvalidGrantError("resource 不匹配");
const requestedScopes = scopes ?? stored.scopes;
if (requestedScopes.some((scope) => !stored.scopes.includes(scope))) throw new InvalidGrantError("不能扩大授权范围");
this.db.prepare("DELETE FROM refresh_tokens WHERE token_hash=?").run(hash);
return this.issueTokens(client.client_id, stored.user, requestedScopes, stored.resource);
}
async verifyAccessToken(token: string): Promise<AuthInfo> {
const row = this.db.prepare("SELECT payload,expires_at FROM access_tokens WHERE token_hash=?").get(hashToken(token)) as JsonRow | undefined;
if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("Access Token 无效或已过期");
const stored = deserialize<StoredToken>(String(row.payload));
return {
token,
clientId: stored.clientId,
scopes: stored.scopes,
expiresAt: Math.floor(stored.expiresAt / 1000),
...(stored.resource ? { resource: new URL(stored.resource) } : {}),
extra: { userId: stored.user.userId, ...(stored.user.name ? { userName: stored.user.name } : {}) },
};
}
async revokeToken(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void> {
const hash = hashToken(request.token);
const refresh = this.db.prepare("SELECT payload FROM refresh_tokens WHERE token_hash=?").get(hash) as JsonRow | undefined;
if (refresh) {
const stored = deserialize<StoredToken>(String(refresh.payload));
if (stored.clientId === client.client_id) {
this.deleteGrantTokens(stored.clientId, stored.user.userId);
return;
}
}
const access = this.db.prepare("SELECT payload FROM access_tokens WHERE token_hash=?").get(hash) as JsonRow | undefined;
if (access && deserialize<StoredToken>(String(access.payload)).clientId === client.client_id) {
this.db.prepare("DELETE FROM access_tokens WHERE token_hash=?").run(hash);
}
}
userFromAuth(auth: AuthInfo): AuthenticatedDingTalkUser {
const userId = auth.extra?.userId;
if (typeof userId !== "string" || !userId) throw new InvalidGrantError("会话缺少钉钉用户身份");
const name = auth.extra?.userName;
return { userId, ...(typeof name === "string" && name ? { name } : {}) };
}
hasDingTalkIdentity(userId: string): boolean {
return Boolean(this.db.prepare("SELECT 1 FROM identities WHERE user_id=?").get(userId));
}
/** Returns a current upstream user token for server-side per-user operations. */
async getDingTalkUserAccessToken(userId: string): Promise<string> {
const row = this.db.prepare("SELECT name,token_cipher,expires_at FROM identities WHERE user_id=?").get(userId) as JsonRow | undefined;
if (!row) throw new InvalidGrantError("当前用户尚未完成钉钉授权");
let token = this.decryptTokenCipher(String(row.token_cipher));
if (Number(row.expires_at) <= Date.now() + 60_000) {
if (!token.refreshToken) throw new InvalidGrantError("钉钉用户授权已过期,请重新登录");
const active = this.userRefreshes.get(userId);
if (active) return active;
const refresh = (async () => {
const refreshed = await this.refreshDingTalkToken(token.refreshToken!);
this.saveIdentity({ userId, ...(typeof row.name === "string" && row.name ? { name: row.name } : {}) }, refreshed);
if (!refreshed.accessToken) throw new InvalidGrantError("钉钉用户授权缺少 Access Token");
return refreshed.accessToken;
})();
this.userRefreshes.set(userId, refresh);
try { return await refresh; } finally { this.userRefreshes.delete(userId); }
}
if (!token.accessToken) throw new InvalidGrantError("钉钉用户授权缺少 Access Token");
return token.accessToken;
}
audit(userId: string, action: string, reference: string | undefined, outcome: string): void {
this.db.prepare("INSERT INTO audit_events(user_id,action,reference_hash,outcome,created_at) VALUES(?,?,?,?,?)")
.run(userId, action, reference ? hashToken(reference) : null, outcome, Date.now());
}
close(): void { clearInterval(this.cleanupTimer); this.db.close(); }
private getClient(clientId: string): OAuthClientInformationFull | undefined {
const row = this.db.prepare("SELECT metadata FROM oauth_clients WHERE client_id=?").get(clientId) as JsonRow | undefined;
if (row) this.db.prepare("UPDATE oauth_clients SET last_used_at=? WHERE client_id=?").run(Date.now(), clientId);
return row ? deserialize<OAuthClientInformationFull>(String(row.metadata)) : undefined;
}
private registerClient(client: OAuthClientInformationFull): OAuthClientInformationFull {
if (!client.client_id) client.client_id = randomUUID();
if (!client.redirect_uris.every(safeRedirectUri)) throw new InvalidClientMetadataError("仅允许 HTTPS 或本机回环 redirect_uri");
const normalized: OAuthClientInformationFull = {
...client,
token_endpoint_auth_method: client.token_endpoint_auth_method ?? "none",
grant_types: client.grant_types ?? ["authorization_code", "refresh_token"],
response_types: client.response_types ?? ["code"],
client_id_issued_at: client.client_id_issued_at ?? Math.floor(Date.now() / 1000),
};
const existing = this.db.prepare("SELECT 1 FROM oauth_clients WHERE client_id=?").get(normalized.client_id);
const count = this.db.prepare("SELECT COUNT(*) AS count FROM oauth_clients").get() as JsonRow;
if (!existing && Number(count.count) >= 10_000) throw new InvalidClientMetadataError("OAuth 客户端注册数量已达安全上限");
const now = Date.now();
this.db.prepare("INSERT OR REPLACE INTO oauth_clients(client_id,metadata,created_at,last_used_at) VALUES(?,?,?,?)")
.run(normalized.client_id, serialize(normalized), now, now);
return normalized;
}
private readCode(code: string): StoredCode {
const row = this.db.prepare("SELECT payload,expires_at FROM authorization_codes WHERE code_hash=?").get(hashToken(code)) as JsonRow | undefined;
if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("授权码无效或已过期");
return deserialize<StoredCode>(String(row.payload));
}
private readPendingLogin(loginState: string): StoredAuthorization | StoredDeviceLogin {
const row = this.db.prepare("SELECT payload,expires_at FROM login_requests WHERE login_state=?")
.get(loginState) as JsonRow | undefined;
if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("登录请求无效或已过期");
return deserialize<StoredAuthorization | StoredDeviceLogin>(String(row.payload));
}
private issueTokens(clientId: string, user: AuthenticatedDingTalkUser, scopes: string[], resource?: string): OAuthTokens {
const accessToken = randomToken(32);
const refreshToken = randomToken(48);
const access: StoredToken = {
clientId, user, scopes, ...(resource ? { resource } : {}),
expiresAt: Date.now() + this.auth.accessTokenTtlSeconds * 1_000,
};
const refresh: StoredToken = {
...access,
expiresAt: Date.now() + this.auth.refreshTokenTtlSeconds * 1_000,
};
this.db.prepare("INSERT INTO access_tokens(token_hash,payload,expires_at) VALUES(?,?,?)")
.run(hashToken(accessToken), serialize(access), access.expiresAt);
this.db.prepare("INSERT INTO refresh_tokens(token_hash,payload,expires_at) VALUES(?,?,?)")
.run(hashToken(refreshToken), serialize(refresh), refresh.expiresAt);
return {
access_token: accessToken,
refresh_token: refreshToken,
token_type: "bearer",
expires_in: this.auth.accessTokenTtlSeconds,
scope: scopes.join(" "),
};
}
private async exchangeDingTalkCode(code: string): Promise<DingTalkTokenResponse> {
const response = await this.fetchFn(`${this.config.apiBaseUrl}/v1.0/oauth2/userAccessToken`, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ clientId: this.config.clientId, clientSecret: this.config.clientSecret, code, grantType: "authorization_code" }),
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
});
const data = await response.json() as DingTalkTokenResponse & { message?: string };
if (!response.ok || !data.accessToken) throw new InvalidGrantError(data.message ?? "钉钉授权码交换失败");
if (data.corpId && data.corpId !== this.config.corpId) throw new InvalidGrantError("该钉钉账号不属于允许的企业");
return data;
}
private dingTalkAuthorizeUrl(loginState: string): string {
const callback = new URL("/oauth/dingtalk/callback", this.auth.issuerUrl);
const target = new URL("/oauth2/auth", DINGTALK_OAUTH_AUTHORIZE_ORIGIN);
target.searchParams.set("redirect_uri", callback.toString());
target.searchParams.set("response_type", "code");
target.searchParams.set("client_id", this.config.clientId);
target.searchParams.set("scope", "openid corpid");
target.searchParams.set("corpId", this.config.corpId!);
target.searchParams.set("state", loginState);
target.searchParams.set("prompt", "consent");
return target.toString();
}
private async refreshDingTalkToken(refreshToken: string): Promise<DingTalkTokenResponse> {
const response = await this.fetchFn(`${this.config.apiBaseUrl}/v1.0/oauth2/userAccessToken`, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
clientId: this.config.clientId,
clientSecret: this.config.clientSecret,
refreshToken,
grantType: "refresh_token",
}),
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
});
const data = await response.json() as DingTalkTokenResponse & { message?: string };
if (!response.ok || !data.accessToken) throw new InvalidGrantError(data.message ?? "钉钉用户授权刷新失败,请重新登录");
if (data.corpId && data.corpId !== this.config.corpId) throw new InvalidGrantError("刷新后的钉钉账号不属于允许的企业");
return { ...data, refreshToken: data.refreshToken ?? refreshToken };
}
private async resolveDingTalkIdentity(token: DingTalkTokenResponse): Promise<AuthenticatedDingTalkUser> {
const response = await this.fetchFn(`${this.config.apiBaseUrl}/v1.0/contact/users/me`, {
headers: { "x-acs-dingtalk-access-token": token.accessToken!, accept: "application/json" },
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
});
const me = await response.json() as Record<string, unknown>;
if (!response.ok) throw new InvalidGrantError("无法读取钉钉登录用户身份");
const corpId = stringField(me, "corpId", "corp_id");
if (corpId && corpId !== this.config.corpId) throw new InvalidGrantError("该钉钉账号不属于允许的企业");
const directUserId = stringField(me, "userId", "userid");
const unionId = stringField(me, "unionId", "unionid");
let userId: string | undefined;
if (unionId) {
userId = await this.userIdByUnionId(unionId);
if (!userId) throw new InvalidGrantError("该钉钉账号不是允许企业的成员");
if (directUserId && directUserId !== userId) throw new InvalidGrantError("钉钉登录身份与企业成员映射不一致");
} else if (corpId === this.config.corpId) {
userId = directUserId;
} else {
throw new InvalidGrantError("无法确认该钉钉账号属于允许的企业");
}
if (!userId) throw new InvalidGrantError("钉钉登录结果缺少企业 userId");
const name = stringField(me, "nick", "name");
return { userId, ...(name ? { name } : {}) };
}
private async userIdByUnionId(unionId: string): Promise<string | undefined> {
const appToken = await this.getAppAccessToken();
const response = await this.fetchFn(`${this.config.oapiBaseUrl}/topapi/user/getbyunionid?access_token=${encodeURIComponent(appToken)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ unionid: unionId }),
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
});
const value = await response.json() as Record<string, unknown>;
const result = value.result && typeof value.result === "object" ? value.result as Record<string, unknown> : value;
return stringField(result, "userid", "userId");
}
private async getAppAccessToken(): Promise<string> {
if (this.appToken && this.appToken.expiresAt > Date.now() + 60_000) return this.appToken.value;
const response = await this.fetchFn(`${this.config.apiBaseUrl}/v1.0/oauth2/accessToken`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ appKey: this.config.clientId, appSecret: this.config.clientSecret }),
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
});
const data = await response.json() as { accessToken?: string; expireIn?: number };
if (!response.ok || !data.accessToken) throw new InvalidGrantError("无法取得钉钉应用 Access Token");
this.appToken = { value: data.accessToken, expiresAt: Date.now() + (data.expireIn ?? 7200) * 1_000 };
return data.accessToken;
}
private saveIdentity(user: AuthenticatedDingTalkUser, token: DingTalkTokenResponse): void {
const expiresAt = Date.now() + (token.expireIn ?? token.expiresIn ?? 7200) * 1_000;
const cipher = this.encrypt(JSON.stringify(token));
this.db.prepare(`INSERT INTO identities(user_id,name,token_cipher,expires_at,updated_at) VALUES(?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET
name=COALESCE(excluded.name, identities.name), token_cipher=excluded.token_cipher,
expires_at=excluded.expires_at, updated_at=excluded.updated_at`)
.run(user.userId, user.name ?? null, cipher, expiresAt, Date.now());
}
private encrypt(plaintext: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", this.encryptionKey, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
return `${iv.toString("base64url")}.${encrypted.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}`;
}
decryptIdentityToken(userId: string): DingTalkTokenResponse | undefined {
const row = this.db.prepare("SELECT token_cipher FROM identities WHERE user_id=?").get(userId) as JsonRow | undefined;
if (!row) return undefined;
return this.decryptTokenCipher(String(row.token_cipher));
}
private decryptTokenCipher(value: string): DingTalkTokenResponse {
const [ivValue, payloadValue, tagValue] = value.split(".");
if (!ivValue || !payloadValue || !tagValue) throw new InvalidGrantError("钉钉用户令牌存储损坏,请重新登录");
const decipher = createDecipheriv("aes-256-gcm", this.encryptionKey, Buffer.from(ivValue, "base64url"));
decipher.setAuthTag(Buffer.from(tagValue, "base64url"));
const plaintext = Buffer.concat([decipher.update(Buffer.from(payloadValue, "base64url")), decipher.final()]);
return JSON.parse(plaintext.toString("utf8")) as DingTalkTokenResponse;
}
private cleanup(): void {
const now = Date.now();
for (const table of ["login_requests", "authorization_codes", "access_tokens", "refresh_tokens", "device_codes"] as const) {
this.db.prepare(`DELETE FROM ${table} WHERE expires_at < ?`).run(now);
}
this.db.prepare("DELETE FROM audit_events WHERE created_at < ?").run(now - 30 * 24 * 60 * 60_000);
const staleClients = this.db.prepare("SELECT client_id FROM oauth_clients WHERE last_used_at < ? LIMIT 1000")
.all(now - 180 * 24 * 60 * 60_000) as Array<Record<string, unknown>>;
for (const row of staleClients) {
const clientId = String(row.client_id);
if (!this.clientHasLiveArtifacts(clientId, now)) {
this.db.prepare("DELETE FROM oauth_clients WHERE client_id=?").run(clientId);
}
}
}
private ensureOAuthClientLastUsedColumn(): void {
const columns = this.db.prepare("PRAGMA table_info(oauth_clients)").all() as Array<Record<string, unknown>>;
if (!columns.some((column) => column.name === "last_used_at")) {
this.db.exec("ALTER TABLE oauth_clients ADD COLUMN last_used_at INTEGER NOT NULL DEFAULT 0");
this.db.prepare("UPDATE oauth_clients SET last_used_at=created_at WHERE last_used_at=0").run();
}
}
private clientHasLiveArtifacts(clientId: string, now: number): boolean {
const needle = `%${clientId}%`;
for (const table of ["access_tokens", "refresh_tokens", "device_codes", "authorization_codes", "login_requests"] as const) {
const row = this.db.prepare(`SELECT 1 FROM ${table} WHERE expires_at >= ? AND payload LIKE ? LIMIT 1`).get(now, needle);
if (row) return true;
}
return false;
}
private deleteGrantTokens(clientId: string, userId: string): void {
for (const table of ["access_tokens", "refresh_tokens"] as const) {
const rows = this.db.prepare(`SELECT token_hash,payload FROM ${table}`).all() as Array<Record<string, unknown>>;
for (const row of rows) {
const token = deserialize<StoredToken>(String(row.payload));
if (token.clientId === clientId && token.user.userId === userId) {
this.db.prepare(`DELETE FROM ${table} WHERE token_hash=?`).run(String(row.token_hash));
}
}
}
}
}
function serialize(value: unknown): string {
return JSON.stringify(value, (_key, child) => child instanceof URL ? child.toString() : child);
}
function deserialize<T>(value: string): T { return JSON.parse(value) as T; }
function randomToken(bytes: number): string { return randomBytes(bytes).toString("base64url"); }
function hashToken(value: string): string { return createHash("sha256").update(value).digest("hex"); }
function safeRedirectUri(value: string): boolean {
try {
const parsed = new URL(value);
return parsed.protocol === "https:" || (parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(parsed.hostname));
} catch { return false; }
}
function loopbackRedirectMatches(registered: string[], requested: string): boolean {
try {
const target = new URL(requested);
if (target.protocol !== "http:" || !["localhost", "127.0.0.1", "::1", "[::1]"].includes(target.hostname)) return false;
return registered.some((value) => {
const expected = new URL(value);
return expected.protocol === target.protocol && expected.hostname === target.hostname
&& expected.pathname === target.pathname && expected.search === target.search;
});
} catch { return false; }
}
function resourceMatches(expected: URL | undefined, actual: URL | undefined): boolean {
return expected === undefined ? actual === undefined : actual?.toString() === expected.toString();
}
function stringField(record: Record<string, unknown>, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return undefined;
}
function readableUserCode(): string {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const bytes = randomBytes(8);
const value = [...bytes].map((byte) => alphabet[byte % alphabet.length]).join("");
return `${value.slice(0, 4)}-${value.slice(4)}`;
}
function normalizeUserCode(value: string): string {
const compact = value.toUpperCase().replace(/[^A-Z2-9]/g, "");
return compact.length === 8 ? `${compact.slice(0, 4)}-${compact.slice(4)}` : value.trim().toUpperCase();
}
+652
View File
@@ -0,0 +1,652 @@
import { randomUUID } from "node:crypto";
import express, { type NextFunction, type Request, type Response } from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { hostHeaderValidation } from "@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js";
import { getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js";
import { ApprovalService } from "./approval-service.js";
import { errorMessage } from "./errors.js";
import type { AppConfig } from "./config.js";
import { DingTalkClient } from "./dingtalk.js";
import type { XftApprovalProvider } from "./xft.js";
import { XftOpenApiClient } from "./xft-open-api.js";
import { UnifiedApprovalService, type XftChallengeProvider } from "./unified-approval.js";
import type { OfficialOaReturnClient } from "./unified-approval.js";
import { DINGTALK_OAUTH_AUTHORIZE_ORIGIN, DingTalkMcpOAuthProvider } from "./oauth.js";
import type { AuthenticatedDingTalkUser } from "./xft.js";
import { InvoiceDeduplicationService, InvoiceDedupStore, invoiceDedupDatabasePath } from "./invoice-dedup.js";
type TransportEntry =
| { kind: "streamable"; transport: StreamableHTTPServerTransport; userId: string; createdAt: number; lastSeenAt: number }
| { kind: "sse"; transport: SSEServerTransport; userId: string; createdAt: number; lastSeenAt: number };
const MCP_SESSION_IDLE_TTL_MS = 2 * 60 * 60_000;
const MCP_SESSION_MAX_AGE_MS = 24 * 60 * 60_000;
const MCP_SESSION_MAX_PER_USER = 8;
const MCP_SESSION_MAX_TOTAL = 1000;
export const APPROVAL_MCP_INSTRUCTIONS = [
`先调用 approval_list_todos 获取当前待办。处理单张或部分待办时使用 approvalId;用户明确要求处理本次查询返回的整组时优先使用 selectionId。每次最多处理 ${APPROVAL_BATCH_LIMIT} 条;超过时可使用同一次原始列表中的 approvalId 分组,但每组都必须符合用户授权范围。`,
"查询本人处理过或本人发起的审批时调用 approval_list_history;默认最近30天,可用 startDate/endDate 指定最长120天的时间窗口。用其只读 approvalId 获取详情;历史引用不能用于 approval_get_actions 或 approval_process_batch,也不会返回 selectionId。",
"费用复核优先调用 approval_get_detail(view=expense);summary 用于一般详情,raw 仅用于分页补充字段。",
"用于审批动作的 approvalId 和 selectionId 必须来自 approval_list_todos;approval_list_history 返回的 approvalId 只能读取详情。同意/拒绝无需逐张调用 approval_get_actions,网关会在写入前实时复核。只有退回必须先调用 approval_get_actions 并使用其 targetId,禁止自行构造。",
"只有用户明确要求处理审批时,才调用 approval_process_batch;写操作不会二次确认。开始写入前会先校验整批 approvalId 和 targetId;任一引用无效则整批零写入。重新拉取待办后必须重新确认目标,禁止自动补偿或自动重试。",
"钉钉开放接口无法预检部分模板的签字等业务规则;要求手写签名时返回客户端处理链接。处理结果中 failed 表示操作被拒绝且未生效;除非用户看过原因后再次明确要求,否则不要重试。succeeded 表示节点提交成功;completed 才表示无需当前用户继续审批。uncertain/outcome_uncertain 表示已经提交但未确认,禁止自动重试,应稍后重新列出待办。followUpRequired 大于 0 时应重新列出待办并告知用户。",
"出现 authChallenge 时,工具不会通过其他消息通道自动发送内容。必须把 directUrl 原样写入当前面向用户的回复;有二维码图片时直接作为当前回复附件,禁止转成 ASCII,也不得只声称已生成或已推送。",
"发起审批范围仅包括薪福通已支持模板,以及 6 个钉钉候选模板:北极大的出差申请、外出、业务招待申请,捷康的差旅费用报销、业务招待费用报销、业务招待申请。禁止绕过范围限制。固定使用 approval_list_submission_templates → approval_get_submission_schema → approval_prepare_submission。薪福通部门或城市编码必须用 approval_get_submission_options 实时查询,不得猜测。必须先检查 submissionCapability;client_only 时停止 API 构造并引导钉钉客户端。读取 Schema 后,逐项核对用户已提供的信息;必填字段缺失或含糊时必须继续询问且不得猜测,直到全部完整。必须列出尚未填写的非必填字段并询问用户是否补充,用户可以明确跳过。prepare 若返回缺失、无效或需自选处理人的字段,继续询问后重新 prepare。prepare 成功后必须向用户展示完整预览并另行取得明确确认;确认后才用完全相同的内容调用 approval_submit。不要让用户或 Agent 提供发起人 userId、薪福通员工号或审批状态。",
"approval_prepare_submission 会自动检查当前用户近30天本人发起的钉钉与薪福通审批是否使用了相同发票。若返回 duplicate_confirmation_required 或 duplicate_check_unavailable,本次没有生成 confirmationId;必须展示匹配单号、状态和查重覆盖情况并询问用户。只有用户明确表示知晓重复或查重不完整仍要提交后,才可将 allowDuplicateInvoices=true 原样重新 prepare。不得自行代替用户确认。",
"approval_submit 发起后会返回实时核验结果;仍在审批中的单据会带 revokeId,按企业规则即时完成的薪福通流程返回 completed 且不可撤回。只有用户明确要求撤销该本人审批时才调用 approval_revoke_submission;禁止用撤销代替拒绝别人的待办,禁止自动重试 completed、failed 或 outcome_uncertain。薪福通创建或撤回需要复核时使用只读 approval_get_submission_status,它会实时校验本人已发起单据归属,但不会再次执行写操作。",
].join("\n");
export function createApprovalMcpServer(
config: AppConfig,
service: ApprovalService,
xft?: XftApprovalProvider,
xftChallenges?: XftChallengeProvider,
officialOaReturns?: OfficialOaReturnClient,
authenticatedUser?: AuthenticatedDingTalkUser,
audit?: (userId: string, action: string, reference: string | undefined, outcome: string) => void,
invoiceDedup?: InvoiceDeduplicationService,
): McpServer {
const server = new McpServer(
{ name: "dingtalk-approval-mcp", version: "1.1.0-rc.8" },
{ instructions: APPROVAL_MCP_INSTRUCTIONS },
);
const unified = new UnifiedApprovalService(config, service, xft, xftChallenges, officialOaReturns, invoiceDedup);
const user = authenticatedUser ?? config.trustedLocalUser;
if (!user) throw new Error("MCP 会话缺少已认证用户");
server.registerTool("approval_list_todos", {
title: "列出我的审批待办",
description: "审批操作的入口。通过同一登录身份实时列出当前用户在钉钉OA和薪福通中的待办,并取得后续接口必须使用的approvalId;列表尽可能直接包含金额、UTC提交时间及Asia/Shanghai本地时间。",
inputSchema: {
source: z.enum(["all", "dingtalk", "xft"]).default("all"),
query: z.string().max(200).default(""),
limit: z.number().int().min(1).max(200).default(50),
},
annotations: { readOnlyHint: true, destructiveHint: false },
}, async ({ source, query, limit }) => compactResult(
"已实时查询审批待办。",
await unified.listTodos(user, source, limit, query),
xftChallenges,
));
server.registerTool("approval_list_history", {
title: "列出我的审批历史",
description: "通过同一登录身份实时查询钉钉OA和薪福通历史。scope=handled表示当前用户已经处理过的审批;scope=submitted表示当前用户本人发起的审批。默认最近30天,可指定最长120天的时间窗口。返回只读approvalId,可继续读取详情但不能执行审批动作。",
inputSchema: {
scope: z.enum(["handled", "submitted"]),
source: z.enum(["all", "dingtalk", "xft"]).default("all"),
query: z.string().max(200).default(""),
limit: z.number().int().min(1).max(200).default(50),
startDate: z.string().max(40).default("").describe("可选;YYYY-MM-DD(按 Asia/Shanghai)或带时区 ISO 8601;不填时默认最近30天"),
endDate: z.string().max(40).default("").describe("可选且包含结束日;格式同 startDate;单次跨度最多120天"),
},
annotations: { readOnlyHint: true, destructiveHint: false },
}, async ({ scope, source, query, limit, startDate, endDate }) => compactResult(
scope === "handled" ? "已实时查询我处理过的审批。" : "已实时查询我发起的审批。",
await unified.listHistory(user, scope, source, limit, query, startDate, endDate),
xftChallenges,
));
server.registerTool("approval_get_detail", {
title: "获取审批详情",
description: "使用approval_list_todos或approval_list_history返回的approvalId读取实时详情。历史引用会重新校验本人处理或本人发起关系且保持只读。一般查看用summary,费用复核优先用expense读取结构化expenseItems;只有缺少字段时才用经过过滤和硬限额保护的raw分页视图。",
inputSchema: {
approvalId: z.string().min(20).max(4096).describe("必须原样使用 approval_list_todos 或 approval_list_history 返回的短期签名引用"),
view: z.enum(["summary", "expense", "raw"]).default("summary"),
rawPath: z.string().max(1024).default(""),
rawCursor: z.number().int().min(0).default(0),
rawLimit: z.number().int().min(1).max(50).default(20),
rawQuery: z.string().max(200).default("").describe("raw 视图可选的有界字段搜索;例如‘关联申请单’"),
},
annotations: { readOnlyHint: true, destructiveHint: false },
}, async ({ approvalId, view, rawPath, rawCursor, rawLimit, rawQuery }) => compactResult(
"已返回审批详情。",
await unified.getDetail(user, approvalId, { view, rawPath, rawCursor, rawLimit, rawQuery }),
xftChallenges,
));
server.registerTool("approval_get_actions", {
title: "获取审批可执行动作",
description: "使用approval_list_todos返回的approvalId实时检查任务与退回目标。钉钉开放接口无法预检部分签字等业务规则;要求客户端处理时会返回明确链接。执行退回前必须先调用本工具,并将其返回的targetId原样传给approval_process_batch。",
inputSchema: { approvalId: z.string().min(20).max(4096).describe("必须原样使用 approval_list_todos 返回的短期签名引用") },
annotations: { readOnlyHint: true, destructiveHint: false },
}, async ({ approvalId }) => compactResult(
"已返回当前可执行审批动作。",
await unified.getActions(user, approvalId),
xftChallenges,
));
server.registerTool("approval_process_batch", {
title: "处理审批",
description: `仅在用户明确要求时调用,写操作不二次确认。一次最多 ${APPROVAL_BATCH_LIMIT} 条;处理整组查询结果时传selectionId,处理单张或部分待办时传items。网关先校验整批引用,任一无效则零写入;不会自动补偿或重试。同意/拒绝无需逐张预检;退回必须逐张传approval_get_actions返回的targetId。succeeded表示节点提交成功,completed表示无需继续审批,followUpRequired表示仍有本人后续节点,outcome_uncertain禁止自动重试。`,
inputSchema: {
action: z.enum(["approve", "reject", "return"]),
remark: z.string().max(1000).default(""),
selectionId: z.string().min(20).max(4096).optional().describe("approval_list_todos 返回、精确绑定整组列表结果的短期引用;仅用于整组同意或拒绝"),
items: z.array(z.object({
approvalId: z.string().min(20).max(4096).describe("approval_list_todos 返回的签名引用;不是业务编号或 taskId"),
targetId: z.string().min(20).max(4096).optional().describe("仅退回时使用 approval_get_actions 返回的不透明签名引用;不是上游 taskId"),
})).max(APPROVAL_BATCH_LIMIT, `一次最多处理 ${APPROVAL_BATCH_LIMIT} 条审批`).default([]),
},
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
}, async ({ action, remark, selectionId, items }) => {
try {
const result = await unified.processBatch(user, action, remark, items, selectionId);
audit?.(
user.userId,
action,
selectionId ?? items.map((item) => item.approvalId).join(","),
result.status === "preflight_failed" ? "preflight_failed" : "completed",
);
return compactResult(approvalBatchSummary(result), result, xftChallenges);
} catch (error) {
audit?.(user.userId, action, selectionId ?? items.map((item) => item.approvalId).join(","), "failed");
throw error;
}
});
server.registerTool("approval_list_submission_templates", {
title: "列出可发起审批模板",
description: "实时列出允许检查发起能力的模板:薪福通已支持模板,以及北极大的出差申请、外出、业务招待申请和捷康的差旅费用报销、业务招待费用报销、业务招待申请;返回短期templateId。是否可由 API 构造必须再看 approval_get_submission_schema 的 submissionCapability。",
inputSchema: {
source: z.enum(["all", "dingtalk", "xft"]).default("all"),
query: z.string().max(200).default(""),
limit: z.number().int().min(1).max(100).default(50),
},
annotations: { readOnlyHint: true, destructiveHint: false },
}, async ({ source, query, limit }) => compactResult(
"已实时列出当前用户可发起的审批模板。",
await unified.listSubmissionTemplates(user, source, limit, query),
));
server.registerTool("approval_get_submission_schema", {
title: "获取发起审批表单结构",
description: "使用模板列表返回的templateId实时读取结构化字段、必填状态和填写约束。Agent必须持续询问直至必填字段齐全,并主动提醒可选字段;不会返回超大的上游原始Schema。",
inputSchema: {
templateId: z.string().min(20).max(4096).describe("必须原样使用 approval_list_submission_templates 返回的短期签名引用"),
},
annotations: { readOnlyHint: true, destructiveHint: false },
}, async ({ templateId }) => compactResult(
"已返回可填写字段和约束。",
await unified.getSubmissionSchema(user, templateId),
));
server.registerTool("approval_get_submission_options", {
title: "查询发起审批可用选项",
description: "使用薪福通templateId实时查询当前登录用户本人的承担部门,或按名称搜索城市编码。只返回可填写的code/label,不返回成员档案、员工号或原始上游数据。",
inputSchema: {
templateId: z.string().min(20).max(4096),
kind: z.enum(["department", "city"]),
query: z.string().max(100).default(""),
limit: z.number().int().min(1).max(100).default(50),
},
annotations: { readOnlyHint: true, destructiveHint: false },
}, async ({ templateId, kind, query, limit }) => compactResult(
"已实时返回可填写选项。",
await unified.getSubmissionOptions(user, templateId, kind, query, limit),
));
const submissionInputSchema = {
templateId: z.string().min(20).max(4096).describe("模板列表返回的短期签名引用"),
values: z.record(z.string().min(1).max(200), z.unknown()).describe("按模板Schema填写的表单业务字段;不得包含发起人身份、员工号或审批状态"),
deptId: z.number().int().default(-1).describe("仅钉钉OA使用;默认传-1,由服务器实时解析并验证当前登录用户所属部门"),
targetSelectActioners: z.array(z.object({
actionerKey: z.string().min(1).max(500),
actionerStaffIds: z.array(z.string().min(1).max(500)).min(1).max(50),
})).max(20).default([]),
allowDuplicateInvoices: z.boolean().default(false).describe("仅当 prepare 已展示重复/疑似重复或查重覆盖不完整,且用户明确确认仍要提交时设为 true;不得由 Agent 自行开启"),
};
server.registerTool("approval_prepare_submission", {
title: "校验并预览新审批",
description: "仅在用户已补齐所有必填字段并选择是否填写可选字段后调用。实时复核模板和字段构造能力,并自动查重本人近30天钉钉/薪福通发票。若返回缺失或无效字段,Agent必须继续询问;若为 client_only 则停止 API 构造。重复风险或查重不完整时不生成confirmationId,必须展示结果并询问用户;只有用户明确接受后才以allowDuplicateInvoices=true重试准备。成功预览仍需另行获得最终提交确认。",
inputSchema: submissionInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false },
}, async ({ templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices }) => {
const result = await unified.prepareSubmission(user, { templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices });
const summary = result.confirmationId
? "审批尚未提交;已生成待用户确认的完整预览。"
: "审批尚未提交;发票查重需要用户决定,本次未生成提交确认。";
return compactResult(summary, result);
});
server.registerTool("approval_submit", {
title: "正式发起审批",
description: "仅在用户看过approval_prepare_submission最终预览并明确确认后调用。必须原样重传相同表单内容、allowDuplicateInvoices决定和一次性confirmationId;服务端再次校验当前登录身份、模板和发票预留。薪福通流程可能进入待审批,也可能按企业规则即时完成并返回completed;两者都禁止自动重试。",
inputSchema: {
confirmationId: z.string().min(20).max(4096).describe("approval_prepare_submission返回的一次性确认引用"),
...submissionInputSchema,
},
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
}, async ({ confirmationId, templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices }) => {
try {
const result = await unified.submitApproval(user, confirmationId, { templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices });
audit?.(user.userId, "submit_approval", confirmationId, "submitted");
return compactResult(
result.status === "completed" ? "审批已发起并按企业流程规则即时完成。"
: "审批已正式提交。",
result,
);
} catch (error) {
audit?.(user.userId, "submit_approval", confirmationId, "failed_or_uncertain");
throw error;
}
});
server.registerTool("approval_revoke_submission", {
title: "撤销本人发起的审批",
description: "仅在用户明确要求撤销时,使用 approval_submit 返回的用户绑定 revokeId 撤销本人刚通过 MCP 发起的审批。身份由服务器确定,禁止自动重试。钉钉撤销已实现;薪福通 recall 在当前企业仍属实验能力,真实调用可能返回 XFTSYS9999,必须以写后状态复核为准。",
inputSchema: {
revokeId: z.string().min(20).max(4096).describe("approval_submit 返回的短期、用户绑定撤销引用"),
remark: z.string().max(1000).default("").describe("可选撤销原因"),
},
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
}, async ({ revokeId, remark }) => {
try {
const result = await unified.revokeSubmission(user, revokeId, remark);
audit?.(user.userId, "revoke_submission", revokeId, String(result.status));
return compactResult(
result.status === "revoked" ? "审批已撤销。"
: result.status === "outcome_uncertain" ? "撤销结果暂不确定;禁止自动重试,请稍后查询状态。"
: "审批未确认撤销成功;禁止自动重试。",
result,
);
} catch (error) {
audit?.(user.userId, "revoke_submission", revokeId, "failed_or_uncertain");
throw error;
}
});
server.registerTool("approval_get_submission_status", {
title: "核验本人已发起审批状态",
description: "只读核验当前登录用户本人已发起的薪福通单据状态。服务端先用本人员工号实时验证归属,再返回精简状态;仍可撤回时签发新的短期revokeId,用于服务重启或原引用遗失后的安全恢复;不返回原始单据正文。",
inputSchema: {
source: z.literal("xft"),
businessId: z.string().regex(/^\d{10,20}$/).describe("approval_submit 返回的薪福通 businessId"),
},
annotations: { readOnlyHint: true, destructiveHint: false },
}, async ({ source, businessId }) => compactResult(
"已实时核验本人已发起单据状态。",
await unified.getSubmissionStatus(user, source, businessId),
));
return server;
}
export async function startHttpServer(config: AppConfig): Promise<void> {
if (config.appMode !== "gateway") throw new Error("HTTP 服务只允许以 APP_MODE=gateway 启动");
const dingTalk = new DingTalkClient(config);
const service = new ApprovalService(dingTalk, config);
const xft = config.xftOpenApi ? new XftOpenApiClient(config) : undefined;
const invoiceDedupStore = new InvoiceDedupStore(invoiceDedupDatabasePath(config), config.approvalRefSecret);
const invoiceDedup = new InvoiceDeduplicationService(config, invoiceDedupStore, service, xft);
const app = express();
// The Node process only listens on loopback, while Nginx preserves the
// public Host header. Keep DNS-rebinding protection and explicitly allow
// both the configured public hostname and local health checks.
app.use(hostHeaderValidation(mcpAllowedHosts(config.publicBaseUrl)));
// The gateway only listens on loopback and is reached through the local
// Nginx reverse proxy. Trust exactly that hop so OAuth rate limiting uses
// the real client IP without accepting spoofed forwarding headers directly.
app.set("trust proxy", "loopback");
// Install exactly one JSON parser so the advertised 1MB limit is the real
// limit; createMcpExpressApp installs an earlier 100KB parser.
app.use(express.json({ limit: "1mb" }));
app.use(express.urlencoded({ extended: false }));
const mcpUrl = new URL("/mcp", config.publicBaseUrl);
const oauth = config.remoteAuth ? new DingTalkMcpOAuthProvider(config) : undefined;
const officialOaReturns: OfficialOaReturnClient | undefined = undefined;
if (!oauth) app.use(loopbackOnly);
if (oauth && config.remoteAuth) {
app.use(mcpAuthRouter({
provider: oauth,
issuerUrl: new URL(config.remoteAuth.issuerUrl),
baseUrl: new URL(config.remoteAuth.issuerUrl),
resourceServerUrl: mcpUrl,
scopesSupported: ["mcp:tools"],
resourceName: "钉钉审批 MCP",
}));
app.get("/oauth/consent", (req, res) => {
try {
const loginState = queryString(req.query.login_state);
if (!loginState) { res.status(400).send("授权请求缺少状态参数"); return; }
const details = oauth.consentDetails(loginState);
setOAuthHtmlHeaders(res);
res.type("html").send(renderConsentPage(loginState, details));
} catch {
setOAuthHtmlHeaders(res);
res.status(400).type("html").send("<!doctype html><meta charset=utf-8><title>授权失败</title><p>授权请求无效或已过期,请返回 Agent 重试。</p>");
}
});
app.post("/oauth/consent", (req, res) => {
try {
const loginState = bodyString(req.body, "login_state");
const decision = bodyString(req.body, "decision");
if (!loginState || decision !== "approve") { res.status(400).send("授权确认无效"); return; }
res.redirect(302, oauth.approveConsent(loginState));
} catch {
res.status(400).send("授权请求无效或已过期,请返回 Agent 重试");
}
});
app.get("/oauth/dingtalk/callback", async (req, res) => {
try {
const code = queryString(req.query.code);
const state = queryString(req.query.state);
if (!code || !state) { res.status(400).send("登录回调缺少 code 或 state"); return; }
res.redirect(302, await oauth.completeDingTalkLogin(code, state));
} catch (error) {
console.error("[dingtalk-approval-mcp] OAuth callback failed", error);
res.status(400).send("钉钉登录失败或已过期,请返回 Agent 后重试");
}
});
app.post("/oauth/device/authorize", (req, res) => {
try {
const clientId = bodyString(req.body, "client_id");
if (!clientId) { res.status(400).json({ error: "invalid_request" }); return; }
const requested = bodyString(req.body, "scope")?.split(/\s+/).filter(Boolean) ?? ["mcp:tools"];
if (requested.some((scope) => scope !== "mcp:tools")) {
res.status(400).json({ error: "invalid_scope" }); return;
}
const resource = bodyString(req.body, "resource");
if (resource && resource !== mcpUrl.toString()) {
res.status(400).json({ error: "invalid_target" }); return;
}
res.setHeader("Cache-Control", "no-store");
res.json(oauth.startDeviceAuthorization(clientId, requested, resource ?? mcpUrl.toString()));
} catch {
res.status(400).json({ error: "invalid_client" });
}
});
app.post("/oauth/device/token", (req, res) => {
try {
const clientId = bodyString(req.body, "client_id");
const deviceCode = bodyString(req.body, "device_code");
if (!clientId || !deviceCode) { res.status(400).json({ error: "invalid_request" }); return; }
const result = oauth.exchangeDeviceCode(clientId, deviceCode);
res.setHeader("Cache-Control", "no-store");
if (typeof result === "string") { res.status(400).json({ error: result }); return; }
res.json(result);
} catch {
res.status(400).json({ error: "expired_token" });
}
});
app.get("/oauth/device", (req, res) => {
const userCode = queryString(req.query.user_code);
if (!userCode) {
res.status(400).type("html").send("<!doctype html><meta charset=utf-8><title>钉钉审批登录</title><p>请使用 Agent 提供的完整登录链接。</p>");
return;
}
try { res.redirect(302, oauth.beginDeviceLogin(userCode)); }
catch { res.status(400).type("html").send("<!doctype html><meta charset=utf-8><title>登录失败</title><p>登录链接无效或已过期,请返回 Agent 重试。</p>"); }
});
app.get("/oauth/device/success", (_req, res) => {
res.setHeader("Cache-Control", "no-store");
res.type("html").send("<!doctype html><meta charset=utf-8><title>登录成功</title><p>钉钉审批已授权,可以关闭此页面并返回 Agent。</p>");
});
}
const authMiddleware = oauth ? requireBearerAuth({
verifier: oauth,
requiredScopes: ["mcp:tools"],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
}) : undefined;
const transports = new Map<string, TransportEntry>();
let pendingTransportSessions = 0;
const pendingTransportSessionsByUser = new Map<string, number>();
const closeTransport = (id: string, entry: TransportEntry) => {
transports.delete(id);
void entry.transport.close().catch(() => undefined);
};
const cleanupTransports = () => {
const now = Date.now();
for (const [id, entry] of transports) {
if (entry.lastSeenAt < now - MCP_SESSION_IDLE_TTL_MS || entry.createdAt < now - MCP_SESSION_MAX_AGE_MS) {
closeTransport(id, entry);
}
}
};
const sessionCapacityAvailable = (userId: string) => transports.size + pendingTransportSessions < MCP_SESSION_MAX_TOTAL
&& [...transports.values()].filter((entry) => entry.userId === userId).length
+ (pendingTransportSessionsByUser.get(userId) ?? 0) < MCP_SESSION_MAX_PER_USER;
const reserveSession = (userId: string) => {
pendingTransportSessions += 1;
pendingTransportSessionsByUser.set(userId, (pendingTransportSessionsByUser.get(userId) ?? 0) + 1);
};
const releaseSessionReservation = (userId: string) => {
pendingTransportSessions = Math.max(0, pendingTransportSessions - 1);
const remaining = Math.max(0, (pendingTransportSessionsByUser.get(userId) ?? 1) - 1);
if (remaining === 0) pendingTransportSessionsByUser.delete(userId);
else pendingTransportSessionsByUser.set(userId, remaining);
};
const transportCleanupTimer = setInterval(cleanupTransports, 10 * 60_000);
transportCleanupTimer.unref();
const mcpHandler = async (req: Request, res: Response) => {
let reservedUserId: string | undefined;
try {
cleanupTransports();
const user = requestUser(config, oauth, req.auth);
const sessionId = headerString(req.headers["mcp-session-id"]);
let entry = sessionId ? transports.get(sessionId) : undefined;
if (entry && entry.kind !== "streamable") {
res.status(400).json({ error: "Invalid MCP session transport" });
return;
}
if (entry && entry.userId !== user.userId) {
res.status(403).json({ error: "MCP session belongs to another authenticated user" });
return;
}
if (entry) entry.lastSeenAt = Date.now();
if (!entry && req.method === "POST" && !sessionId && isInitializeRequest(req.body)) {
if (!sessionCapacityAvailable(user.userId)) {
res.status(429).json({ error: "MCP session limit reached; close an old Agent session and retry" });
return;
}
reserveSession(user.userId);
reservedUserId = user.userId;
const createdAt = Date.now();
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => {
transports.set(id, { kind: "streamable", transport, userId: user.userId, createdAt, lastSeenAt: Date.now() });
},
});
transport.onclose = () => { if (transport.sessionId) transports.delete(transport.sessionId); };
entry = { kind: "streamable", transport, userId: user.userId, createdAt, lastSeenAt: createdAt };
await createApprovalMcpServer(
config, service, xft, undefined, officialOaReturns, user,
oauth ? (userId, action, reference, outcome) => oauth.audit(userId, action, reference, outcome) : undefined,
invoiceDedup,
).connect(transport);
}
if (!entry || entry.kind !== "streamable") {
res.status(sessionId ? 404 : 400).json({ error: "Missing or invalid MCP session" });
return;
}
await entry.transport.handleRequest(req, res, req.body);
} catch (error) { sendServerError(res, error); }
finally { if (reservedUserId) releaseSessionReservation(reservedUserId); }
};
app.all("/mcp", ...(authMiddleware ? [authMiddleware, mcpHandler] : [mcpHandler]));
const sseHandler = async (req: Request, res: Response) => {
const user = requestUser(config, oauth, req.auth);
cleanupTransports();
if (!sessionCapacityAvailable(user.userId)) {
res.status(429).send("MCP session limit reached");
return;
}
const transport = new SSEServerTransport("/messages", res);
const createdAt = Date.now();
transports.set(transport.sessionId, { kind: "sse", transport, userId: user.userId, createdAt, lastSeenAt: createdAt });
res.on("close", () => transports.delete(transport.sessionId));
await createApprovalMcpServer(config, service, xft, undefined, officialOaReturns, user, undefined, invoiceDedup).connect(transport);
};
app.get("/sse", ...(authMiddleware ? [authMiddleware, sseHandler] : [sseHandler]));
const messagesHandler = async (req: Request, res: Response) => {
const user = requestUser(config, oauth, req.auth);
const entry = transports.get(String(req.query.sessionId ?? ""));
if (!entry || entry.kind !== "sse") {
res.status(404).send("Invalid SSE session");
return;
}
if (entry.userId !== user.userId) {
res.status(403).send("Invalid SSE session");
return;
}
entry.lastSeenAt = Date.now();
await entry.transport.handlePostMessage(req, res, req.body);
};
app.post("/messages", ...(authMiddleware ? [authMiddleware, messagesHandler] : [messagesHandler]));
app.get("/healthz", (_req, res) => res.json({ status: "ok", mode: config.appMode }));
await new Promise<void>((resolve, reject) => {
const server = app.listen(config.port, config.bindHost ?? "127.0.0.1", () => resolve());
server.on("error", reject);
});
console.error(`[dingtalk-approval-mcp] listening on :${config.port}; MCP endpoint ${mcpUrl}`);
}
export function mcpAllowedHosts(publicBaseUrl: string): string[] {
return [...new Set([new URL(publicBaseUrl).hostname, "127.0.0.1", "localhost", "[::1]"])];
}
function requestUser(
config: AppConfig,
oauth: DingTalkMcpOAuthProvider | undefined,
auth: AuthInfo | undefined,
): AuthenticatedDingTalkUser {
if (oauth) {
if (!auth) throw new Error("MCP request is not authenticated");
return oauth.userFromAuth(auth);
}
if (!config.trustedLocalUser) throw new Error("本机兼容模式缺少固定用户");
return config.trustedLocalUser;
}
function queryString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function bodyString(value: unknown, key: string): string | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
return queryString((value as Record<string, unknown>)[key]);
}
function setOAuthHtmlHeaders(res: Response): void {
res.setHeader("Cache-Control", "no-store");
res.setHeader("Content-Security-Policy", oauthHtmlContentSecurityPolicy());
res.setHeader("Referrer-Policy", "no-referrer");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
}
export function oauthHtmlContentSecurityPolicy(): string {
return `default-src 'none'; style-src 'unsafe-inline'; form-action 'self' ${DINGTALK_OAUTH_AUTHORIZE_ORIGIN}; frame-ancestors 'none'`;
}
function renderConsentPage(
loginState: string,
details: { clientName: string; scopes: string[]; redirectOrigin?: string; userCode?: string },
): string {
const rows = [
["请求客户端", details.clientName],
...(details.redirectOrigin ? [["授权后返回", details.redirectOrigin]] : []),
...(details.userCode ? [["设备授权码", details.userCode]] : []),
["权限范围", details.scopes.join(" ") || "mcp:tools"],
].map(([label, value]) => `<dt>${htmlEscape(label!)}</dt><dd>${htmlEscape(value!)}</dd>`).join("");
return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>确认钉钉审批授权</title><style>body{font:16px system-ui;max-width:42rem;margin:3rem auto;padding:0 1rem;color:#1f2328}dl{display:grid;grid-template-columns:8rem 1fr;gap:.75rem}dt{font-weight:600}dd{margin:0;overflow-wrap:anywhere}.warning{padding:1rem;background:#fff8c5;border-radius:.5rem}button{font:inherit;padding:.7rem 1.2rem;border:0;border-radius:.4rem;background:#1677ff;color:white;cursor:pointer}</style><h1>确认钉钉审批授权</h1><p class="warning">只有当你刚刚要求当前 Agent 使用钉钉审批时才继续。客户端名称由客户端自行声明,请同时核对返回域名或设备授权码。</p><dl>${rows}</dl><form method="post" action="/oauth/consent"><input type="hidden" name="login_state" value="${htmlEscape(loginState)}"><button name="decision" value="approve" type="submit">确认并前往钉钉授权</button></form></html>`;
}
function htmlEscape(value: string): string {
return value.replace(/[&<>"']/g, (character) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;",
})[character]!);
}
function loopbackOnly(req: Request, res: Response, next: NextFunction): void {
if (!isLoopbackAddress(req.socket.remoteAddress)) {
res.status(403).json({ error: "Local MCP is available only on loopback" });
return;
}
next();
}
export function isLoopbackAddress(value: string | undefined): boolean {
return value === "127.0.0.1" || value === "::1" || value?.startsWith("::ffff:127.") === true;
}
function headerString(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value[0] : value; }
export function compactResult(summary: string, value: unknown, images?: XftChallengeProvider) {
const visibleValue = agentVisibleResult(value);
const serialized = JSON.stringify(visibleValue);
const content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }> = [
{ type: "text", text: serialized === undefined ? summary : `${summary}\n${serialized}` },
];
const challengeId = findAuthChallengeId(value);
const image = challengeId ? images?.getImage?.(challengeId) : undefined;
if (image) {
content.push({ type: "image", data: image.toString("base64"), mimeType: "image/png" });
const textBlock = content[0] as { type: "text"; text: string };
content[0] = { type: "text", text: `${textBlock.text}\n工具不会另行推送登录内容。请把 authChallenge.directUrl(如有)原样写入当前回复,并把附带二维码直接作为当前回复附件;禁止转成 ASCII,不得只声称已生成或已推送。` };
}
return { content, structuredContent: { result: visibleValue } };
}
export function approvalBatchSummary(value: unknown): string {
const result = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
if (result.status === "preflight_failed" || result.writesStarted === false) {
const invalid = Array.isArray(result.invalidItems) ? result.invalidItems.length : 0;
return `审批批次未开始,0 张已写入;发现 ${invalid} 项引用无效。请重新列出待办并让用户重新确认目标,禁止自动重试。`;
}
const total = typeof result.total === "number" ? result.total : 0;
const succeeded = typeof result.succeeded === "number" ? result.succeeded : 0;
const uncertain = typeof result.uncertain === "number" ? result.uncertain : 0;
if (uncertain > 0) return `审批批次有 ${uncertain} 项已提交但结果暂无法确认;禁止自动重试,请稍后重新列出待办。逐项结果如下。`;
if (succeeded === 0) return "审批批次未有任何项目处理成功,逐项原因如下。";
if (succeeded < total) return `审批批次部分成功(${succeeded}/${total}),逐项结果如下。`;
return `审批批次已全部提交成功(${succeeded}/${total}),逐项结果如下。`;
}
function agentVisibleResult(value: unknown): unknown {
if (Array.isArray(value)) return value.map(agentVisibleResult);
if (!value || typeof value !== "object") return value;
const hidden = new Set([
"diagnostics", "response", "taskId", "nextTaskId", "userId", "employeeId", "employeeNumber", "empId",
"enterpriseUserId", "originatorUserid", "originatorDeptId", "appType", "processCode", "processInstanceId",
]);
const record = value as Record<string, unknown>;
const output: Record<string, unknown> = {};
for (const [key, child] of Object.entries(record)) {
if (hidden.has(key)) continue;
if (key === "billId" && record.businessId === undefined && record.businessNo === undefined) {
output.businessId = agentVisibleResult(child);
continue;
}
output[key] = agentVisibleResult(child);
}
return output;
}
function findAuthChallengeId(value: unknown): string | undefined {
if (Array.isArray(value)) {
for (const item of value) { const found = findAuthChallengeId(item); if (found) return found; }
return undefined;
}
if (!value || typeof value !== "object") return undefined;
const record = value as Record<string, unknown>;
if (record.provider === "xft" && record.type === "qr" && typeof record.challengeId === "string") return record.challengeId;
for (const child of Object.values(record)) { const found = findAuthChallengeId(child); if (found) return found; }
return undefined;
}
function sendServerError(res: Response, error: unknown): void {
console.error("[dingtalk-approval-mcp] request failed", error);
if (!res.headersSent) res.status(500).json({ error: "Internal server error" });
}
+27
View File
@@ -0,0 +1,27 @@
declare module "sm-crypto" {
const crypto: {
sm2: {
doSignature(
message: string | number[],
privateKey: string,
options?: { hash?: boolean; userId?: string; der?: boolean; publicKey?: string },
): string;
};
sm3: (input: string | number[], options?: { mode?: "hmac"; key?: string | number[] }) => string;
sm4: {
encrypt(
input: string | number[],
key: string | number[],
options?: { mode?: "cbc"; iv?: string | number[]; padding?: "pkcs#5" | "pkcs#7" | "none"; output?: "array" },
): string | number[];
decrypt(
input: string | number[],
key: string | number[],
options?: { mode?: "cbc"; iv?: string | number[]; padding?: "pkcs#5" | "pkcs#7" | "none"; output?: "array" },
): string | number[];
};
};
export default crypto;
}
File diff suppressed because it is too large Load Diff
+1676
View File
File diff suppressed because it is too large Load Diff
+197
View File
@@ -0,0 +1,197 @@
/** Shared contracts for the official XFT OpenAPI adapter. */
export interface AuthenticatedDingTalkUser {
userId: string;
name?: string;
}
export interface XftTodoItem {
source: "xft";
billId: string;
taskId?: string;
eventSeqId?: string;
title?: string;
applicantName?: string;
applicantDepartment?: string;
applyTime?: string;
amount?: number;
currency?: string;
reason?: string;
allowPass?: boolean;
allowReject?: boolean;
eventType?: string;
}
export type ApprovalHistoryScope = "handled" | "submitted";
export interface ApprovalHistoryRange {
startTime: number;
endTime: number;
}
export interface XftHistoryItem extends Omit<XftTodoItem, "allowPass" | "allowReject"> {
historyScope: ApprovalHistoryScope;
status?: string;
statusName?: string;
completedAt?: string;
}
export type XftApprovalAction = "agree" | "reject" | "return";
export interface XftApprovalBatchItem {
billId: string;
targetNodeId?: string;
}
export type XftApprovalBatchResultItem =
| { billId: string; status: "succeeded"; success: true }
| { billId: string; status: "failed"; success: false; failureConfirmed: true; error: string }
| {
billId: string;
status: "outcome_uncertain";
success: false;
outcomeUncertain: true;
error: string;
};
export type XftSubmissionKind =
| "travel_apply"
| "expense_apply"
| "entertainment_apply"
| "common_apply"
| "use_car_apply"
| "common_reimburse"
| "travel_reimburse";
export interface XftSubmissionTemplate {
eventSeqId: string;
eventNumber: string;
eventName: string;
eventType: string;
kind: XftSubmissionKind;
}
export interface XftSubmissionField {
fieldNumber: string;
name: string;
type: string;
required: boolean;
description?: string;
multiple?: boolean;
options?: unknown[];
moduleType?: string;
valueFormat?: string;
itemFields?: Array<Record<string, unknown>>;
}
export type XftSubmissionOptionKind = "department" | "city";
export interface XftSubmissionOption {
code: string;
label: string;
}
export interface XftApprovalProvider {
authStatus(user: AuthenticatedDingTalkUser): Promise<{
authenticated: true;
identity: Record<string, unknown>;
binding: Record<string, unknown>;
persistedBusinessData: false;
}>;
listMyTodos(user: AuthenticatedDingTalkUser, limit: number, search?: string): Promise<{
items: XftTodoItem[];
totalCount: number;
identity: Record<string, unknown>;
diagnostics: Record<string, unknown>;
}>;
listMyHistory(
user: AuthenticatedDingTalkUser,
scope: ApprovalHistoryScope,
limit: number,
search: string,
range: ApprovalHistoryRange,
): Promise<{
items: XftHistoryItem[];
totalCount: number;
diagnostics: Record<string, unknown>;
}>;
getTodoDetail(user: AuthenticatedDingTalkUser, billId: string): Promise<Record<string, unknown>>;
getHistoryDetail(
user: AuthenticatedDingTalkUser,
scope: ApprovalHistoryScope,
billId: string,
): Promise<Record<string, unknown>>;
listReturnNodes(user: AuthenticatedDingTalkUser, billId: string): Promise<{
billId: string;
nodes: Array<{ nodeId: string; nodeName?: string; approverNames?: string[] }>;
requirements: {
commentRequired: boolean;
signatureRequired: boolean;
canSkipPreviouslyApprovedNodes: boolean;
};
diagnostics: Record<string, unknown>;
}>;
processApprovalBatch(
user: AuthenticatedDingTalkUser,
action: XftApprovalAction,
remark: string,
items: XftApprovalBatchItem[],
): Promise<{
action: XftApprovalAction;
total: number;
succeeded: number;
failed: number;
results: XftApprovalBatchResultItem[];
diagnostics: Record<string, unknown>;
}>;
listSubmissionTemplates(user: AuthenticatedDingTalkUser, limit: number, search?: string): Promise<XftSubmissionTemplate[]>;
getSubmissionSchema(user: AuthenticatedDingTalkUser, template: XftSubmissionTemplate): Promise<{
template: XftSubmissionTemplate;
fields: XftSubmissionField[];
inputContract: Record<string, unknown>;
diagnostics: Record<string, unknown>;
}>;
getSubmissionOptions(
user: AuthenticatedDingTalkUser,
template: XftSubmissionTemplate,
kind: XftSubmissionOptionKind,
query?: string,
limit?: number,
): Promise<{
kind: XftSubmissionOptionKind;
items: XftSubmissionOption[];
diagnostics: Record<string, unknown>;
}>;
validateSubmissionIdentity(user: AuthenticatedDingTalkUser): Promise<{
validated: true;
diagnostics: Record<string, unknown>;
}>;
submitApproval(
user: AuthenticatedDingTalkUser,
template: XftSubmissionTemplate,
values: Record<string, unknown>,
): Promise<{ billId: string; status: "submitted"; diagnostics: Record<string, unknown> }>;
recallSubmission(
user: AuthenticatedDingTalkUser,
billId: string,
reason?: string,
): Promise<{ status: "revoked" | "failed" | "outcome_uncertain"; billId: string; message?: string; diagnostics: Record<string, unknown> }>;
getMySubmissionStatus(
user: AuthenticatedDingTalkUser,
billId: string,
): Promise<{
billId: string;
status: string;
statusName: string;
approvalStatus?: string;
revoked: boolean;
revokeAllowed: boolean;
flowHistory?: Array<{ nodeName?: string; actions: string[] }>;
diagnostics: Record<string, unknown>;
}>;
}
/** Success codes observed on the documented XFT OpenAPI endpoints. */
export function isXftSuccessCode(code: string | undefined): boolean {
if (code === undefined || code === "") return true;
return ["0", "200", "SUCCESS", "SUC0000", "000000", "0000000"].includes(code.toUpperCase());
}