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
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:
+218
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const APPROVAL_BATCH_LIMIT = 20;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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; });
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) => ({
|
||||
"&": "&", "<": "<", ">": ">", "\"": """, "'": "'",
|
||||
})[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" });
|
||||
}
|
||||
Vendored
+27
@@ -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
File diff suppressed because it is too large
Load Diff
+197
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user